mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecce5d9374 | ||
|
|
a053a6a8f0 |
No files matched your search
@@ -29,9 +29,6 @@ reviews:
|
|||||||
- path: "tests/**/*.ts"
|
- path: "tests/**/*.ts"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
||||||
- path: "**/*.go"
|
|
||||||
instructions: |
|
|
||||||
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
|
|
||||||
tools:
|
tools:
|
||||||
github-checks:
|
github-checks:
|
||||||
timeout_ms: 300000
|
timeout_ms: 300000
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
*.go text eol=lf
|
|
||||||
*.mod text eol=lf
|
|
||||||
*.sum text eol=lf
|
|
||||||
+60
-159
@@ -6,109 +6,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
|
||||||
name: Code Quality & Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: make fmt-check
|
|
||||||
|
|
||||||
- name: Vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: deadcode (advisory)
|
|
||||||
continue-on-error: true
|
|
||||||
run: make deadcode
|
|
||||||
|
|
||||||
- name: golangci-lint (advisory)
|
|
||||||
continue-on-error: true
|
|
||||||
run: make lint-static
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Unit Tests & Coverage
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Test with coverage
|
|
||||||
run: |
|
|
||||||
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
|
|
||||||
|
|
||||||
- name: Publish coverage summary
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [ -f coverage.out ]; then
|
|
||||||
go tool cover -func=coverage.out | awk '
|
|
||||||
BEGIN {
|
|
||||||
total = "unknown"
|
|
||||||
rows = ""
|
|
||||||
}
|
|
||||||
$1 == "total:" {
|
|
||||||
total = $3
|
|
||||||
next
|
|
||||||
}
|
|
||||||
{
|
|
||||||
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
print "### 📊 Test Coverage Summary: **" total "**"
|
|
||||||
print "<details><summary>Coverage per function</summary>\n"
|
|
||||||
print "| File | Function | Coverage |"
|
|
||||||
print "| :--- | :--- | :--- |"
|
|
||||||
printf "%s", rows
|
|
||||||
print "</details>"
|
|
||||||
}' >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload coverage artifact
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: zero-coverage-report
|
|
||||||
path: coverage.out
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
name: Smoke (${{ matrix.os }})
|
name: Smoke (${{ matrix.os }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 15
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -117,40 +18,6 @@ jobs:
|
|||||||
- macos-latest
|
- macos-latest
|
||||||
- windows-latest
|
- windows-latest
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Test (Fast Non-Race)
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Build binary
|
|
||||||
run: go run ./cmd/zero-release build
|
|
||||||
|
|
||||||
- name: Smoke binary
|
|
||||||
run: go run ./cmd/zero-release smoke
|
|
||||||
|
|
||||||
race:
|
|
||||||
name: Race Detector
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# A race the detector newly reports can present as a deadlock rather than a
|
|
||||||
# failure, and the default job timeout is six hours. This job is expected to
|
|
||||||
# be the longest in the workflow, so the ceiling is generous but finite.
|
|
||||||
timeout-minutes: 30
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
@@ -163,29 +30,42 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Invoke the Makefile target rather than repeating its flags. `make test`
|
- name: Check formatting
|
||||||
# is already `go test ./... -race -count=1`, so running it here is what
|
if: matrix.os == 'ubuntu-latest'
|
||||||
# stops the project's declared test command and what CI actually runs
|
shell: bash
|
||||||
# from drifting apart again. The race detector needs cgo, which the
|
run: |
|
||||||
# hosted ubuntu runner supplies without an extra setup step.
|
unformatted="$(gofmt -l .)"
|
||||||
- name: Test with the race detector
|
if [ -n "$unformatted" ]; then
|
||||||
run: make test
|
echo "gofmt needed on:" >&2
|
||||||
|
echo "$unformatted" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: go run ./cmd/zero-release build
|
||||||
|
|
||||||
|
- name: Smoke binary
|
||||||
|
run: go run ./cmd/zero-release smoke
|
||||||
|
|
||||||
performance:
|
performance:
|
||||||
name: Performance Smoke
|
name: Performance Smoke
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
@@ -194,39 +74,60 @@ jobs:
|
|||||||
run: go run ./cmd/zero-release build
|
run: go run ./cmd/zero-release build
|
||||||
|
|
||||||
- name: Performance smoke
|
- name: Performance smoke
|
||||||
run: |
|
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||||
mkdir -p dist/perf
|
|
||||||
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
|
||||||
|
|
||||||
- name: Upload performance report
|
- name: Upload performance report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
with:
|
with:
|
||||||
name: zero-performance-smoke
|
name: zero-performance-smoke
|
||||||
path: dist/perf/perf-bench.json
|
path: dist/perf/perf-bench.json
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
|
|
||||||
security:
|
security:
|
||||||
name: Security & Code Health
|
name: Security & code health
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: govulncheck (linux)
|
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||||
run: make vulncheck
|
# their own modules, which can drop below the version go.mod requires and
|
||||||
|
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||||
|
# so all three run under it.
|
||||||
|
- name: Pin toolchain from go.mod
|
||||||
|
run: |
|
||||||
|
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||||
|
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: govulncheck (windows)
|
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||||
run: make vulncheck-windows
|
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||||
|
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||||
|
- name: govulncheck
|
||||||
|
run: go run golang.org/x/vuln/cmd/[email protected] ./...
|
||||||
|
|
||||||
|
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||||
|
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||||
|
# still being wired or removed.
|
||||||
|
- name: deadcode (advisory)
|
||||||
|
continue-on-error: true
|
||||||
|
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||||
|
|
||||||
|
# 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: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||||
@@ -24,7 +24,6 @@ jobs:
|
|||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||||
|
|||||||
@@ -34,15 +34,12 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Checkout requested ref
|
- name: Checkout requested ref
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||||
@@ -132,8 +129,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
# 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
|
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- action.yml
|
- action.yml
|
||||||
- docs/GITHUB_ACTION.md
|
- docs/GITHUB_ACTION.md
|
||||||
- scripts/action-summary.mjs
|
|
||||||
- scripts/action-summary.test.mjs
|
|
||||||
- .github/workflows/zero-action-smoke.yml
|
- .github/workflows/zero-action-smoke.yml
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -14,13 +12,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- action.yml
|
- action.yml
|
||||||
- docs/GITHUB_ACTION.md
|
- docs/GITHUB_ACTION.md
|
||||||
- scripts/action-summary.mjs
|
|
||||||
- scripts/action-summary.test.mjs
|
|
||||||
- .github/workflows/zero-action-smoke.yml
|
- .github/workflows/zero-action-smoke.yml
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate action.yml
|
name: Validate action.yml
|
||||||
@@ -31,14 +24,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
|
|
||||||
- name: Run action-summary test suite
|
|
||||||
run: node --test scripts/action-summary.test.mjs
|
|
||||||
|
|
||||||
- name: Parse action.yml and assert documented inputs
|
- name: Parse action.yml and assert documented inputs
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{".":"0.9.0"}
|
{".":"0.4.0"}
|
||||||
@@ -51,8 +51,10 @@ request, or completing an implementation task:
|
|||||||
or run focused tests with `-race`, when concurrency is affected.
|
or run focused tests with `-race`, when concurrency is affected.
|
||||||
4. **Build**: `go run ./cmd/zero-release build`.
|
4. **Build**: `go run ./cmd/zero-release build`.
|
||||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||||
6. **Advisory lint**: `make lint-static`.
|
6. **Advisory lint**:
|
||||||
7. **Security**: `make vulncheck`.
|
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||||
|
7. **Security**:
|
||||||
|
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||||
tracked changes).
|
tracked changes).
|
||||||
|
|
||||||
@@ -67,93 +69,3 @@ requirements. If a related check fails, fix it. If a required check cannot run
|
|||||||
because of the environment or fails for an unrelated external reason, report
|
because of the environment or fails for an unrelated external reason, report
|
||||||
the exact failure and obtain maintainer direction rather than silently ignoring
|
the exact failure and obtain maintainer direction rather than silently ignoring
|
||||||
it.
|
it.
|
||||||
|
|
||||||
## 4. Common Review Blockers
|
|
||||||
|
|
||||||
These classes drive multi-round reviews. Fix them before requesting review:
|
|
||||||
|
|
||||||
- **Fresh base:** Rebase onto the current PR base (`main` or stacked target)
|
|
||||||
before review. A stale head that rolls back mainline commits is a hard
|
|
||||||
blocker, not a merge-time detail. Resolve conflicts by keeping upstream
|
|
||||||
behavior unless the PR intentionally changes it.
|
|
||||||
- **Platform truth:** Code and tests must pass on Linux, macOS, and Windows.
|
|
||||||
Compare paths after canonicalization (physical/`EvalSymlinks` form); never
|
|
||||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
|
||||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
|
||||||
checks over duplicated helpers that drift.
|
|
||||||
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
|
|
||||||
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
|
|
||||||
`windows-x64`) and the additional supported Android/Termux source-build and
|
|
||||||
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
|
|
||||||
`plan9`, are unsupported and are not a review criterion. Do not block a PR
|
|
||||||
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
|
|
||||||
`plan9` as a fixture for "unsupported".
|
|
||||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
|
||||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
|
||||||
and permission checks. Do not rely on pre-open path resolution
|
|
||||||
(`EvalSymlinks` then open) for containment: that is a check-to-use race.
|
|
||||||
Bind containment at open/use time with rooted or handle-relative,
|
|
||||||
traversal-resistant APIs. If a no-follow API is used, apply it to every
|
|
||||||
traversed component and enforce the platform's reparse-point protections;
|
|
||||||
final-component-only no-follow is insufficient. On multi-step setup, roll back
|
|
||||||
only what this run created; never destroy pre-existing resources you did
|
|
||||||
not create; never report success when cleanup or unlock failed.
|
|
||||||
- **Normalize before matching:** Any transform that REMOVES bytes without
|
|
||||||
leaving a gap is also a reassembler, so it has to run before whatever matches
|
|
||||||
on the result. Redacting by shape and then stripping control bytes lets a
|
|
||||||
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
|
|
||||||
fragments and be rejoined on the way out; comparing a path against a form
|
|
||||||
produced by the same resolver the kernel just used makes a redirect agree with
|
|
||||||
itself. Strip, decode, or canonicalize first, then match. The unsplit value
|
|
||||||
passing is not evidence, so the test needs a split case.
|
|
||||||
- **Atomic shared state:** Write a complete temporary file, then atomically
|
|
||||||
replace the destination so concurrent readers never see a partial write.
|
|
||||||
Exclusive create or a write lock alone is not enough for readers. Serialize
|
|
||||||
the full read-modify-write sequence for lockfiles and shared stores.
|
|
||||||
- **Tests match the claim:** Every behavior or security-boundary change needs a
|
|
||||||
regression test, including the failure path. Path-sensitive logic needs at
|
|
||||||
least one non-Linux path case (or a hermetic fake that exercises the same
|
|
||||||
normalization).
|
|
||||||
- **Honest scope:** PR description, help text, and comments must match what
|
|
||||||
shipped. Wire advertised entry points or shrink the claim. Do not bundle
|
|
||||||
unrelated fixes. Do not widen security allowlists by name alone; check
|
|
||||||
classification and side effects too.
|
|
||||||
- **Host tool version floors:** Zero shells out to `git` and other host tools,
|
|
||||||
and the project does not declare a minimum version for any of them. A flag,
|
|
||||||
subcommand, or config key that is not long-established therefore raises that
|
|
||||||
floor silently, and the failure on an older host is an unhelpful usage error
|
|
||||||
rather than a clear message. Before adding one, check when it landed and say
|
|
||||||
so where a caller would look. Record the floor for the exact subcommand you
|
|
||||||
call, not for the flag in general: `--end-of-options` reached Git's shared
|
|
||||||
parser in 2.24, but `git rev-parse` only accepted it from 2.30, and
|
|
||||||
`git checkout` and `git reset` later still. Likewise `--show-current` is
|
|
||||||
`git branch` 2.22, and `branch.autoSetupMerge=inherit` is 2.35. Then either
|
|
||||||
keep a fallback for older versions or gate the call. A test that depends on a
|
|
||||||
newer feature must skip below its floor instead of failing, and say which
|
|
||||||
version it needs.
|
|
||||||
- **Prove the test fails without the change:** A regression test earns its
|
|
||||||
place only if it fails on the unfixed code, for the reason it claims. Run it
|
|
||||||
both ways and quote the failure in the PR. The recurring miss is not a
|
|
||||||
missing test but a test that never reaches the behavior it names, because an
|
|
||||||
earlier guard rejects the input first, so it passes with and without the fix.
|
|
||||||
When a test targets a specific layer, call that layer directly rather than
|
|
||||||
relying on a public entry point that may refuse earlier.
|
|
||||||
- **Hermetic tests:** Tests must not read or write the developer's real config,
|
|
||||||
cache, or state directories. Redirect every root the code under test
|
|
||||||
resolves, not just config. `os.UserConfigDir` and `os.UserCacheDir` read
|
|
||||||
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` only on Linux and the BSDs. On macOS
|
|
||||||
they derive from `HOME`, and on Windows from `%AppData%` and `%LocalAppData%`.
|
|
||||||
Setting the XDG variables alone still leaves a macOS run writing to the real
|
|
||||||
home, so set `HOME` as well, or take the directory as a parameter. Apply it to
|
|
||||||
every test that reaches the same storage, not only the one where it was first
|
|
||||||
needed; a test that passes on a clean machine and pollutes a real one is a
|
|
||||||
blocker.
|
|
||||||
- **Error codes vary by platform and by flag combination:** A check written
|
|
||||||
against the code one target returns can be silently inert on another while
|
|
||||||
still looking correct. `openat(O_DIRECTORY|O_NOFOLLOW)` on a symlink may
|
|
||||||
report `ENOTDIR` or `ELOOP` depending on the platform, because the kernel can
|
|
||||||
reject the directory mismatch before it ever resolves the link; accept both
|
|
||||||
rather than assuming the one POSIX names. On Windows, `\\?\` and `\\.\`
|
|
||||||
prefixes are not UNC paths, and an `NtCreateFile` information class must match
|
|
||||||
the struct actually passed. Confirm the code each supported target returns
|
|
||||||
instead of reasoning from the man page.
|
|
||||||
-190
@@ -6,196 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||||
tagged. Until then, source builds report the version `dev`.
|
tagged. Until then, source builds report the version `dev`.
|
||||||
|
|
||||||
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
|
|
||||||
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
|
|
||||||
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
|
|
||||||
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
|
|
||||||
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
|
|
||||||
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
|
|
||||||
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
|
|
||||||
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
|
|
||||||
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
|
|
||||||
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
|
|
||||||
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
|
|
||||||
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
|
|
||||||
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
|
|
||||||
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
|
|
||||||
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
|
|
||||||
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
|
|
||||||
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
|
|
||||||
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
|
|
||||||
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
|
|
||||||
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
|
|
||||||
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
|
|
||||||
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
|
|
||||||
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
|
|
||||||
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
|
|
||||||
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
|
|
||||||
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
|
|
||||||
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
|
|
||||||
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
|
|
||||||
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
|
|
||||||
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
|
|
||||||
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
|
|
||||||
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
|
|
||||||
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
|
|
||||||
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
|
|
||||||
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
|
|
||||||
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
|
|
||||||
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
|
|
||||||
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
|
|
||||||
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
|
|
||||||
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
|
|
||||||
|
|
||||||
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **security:** path-jail primitive, credential-store locking, worktree git hardening ([#891](https://github.com/Gitlawb/zero/issues/891)) ([060d38c](https://github.com/Gitlawb/zero/commit/060d38ce716feb7c5b6657b082cd35cb8dda3d75))
|
|
||||||
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
|
|
||||||
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
|
|
||||||
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
|
|
||||||
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
|
|
||||||
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
|
|
||||||
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
|
|
||||||
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
|
|
||||||
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
|
|
||||||
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))
|
|
||||||
|
|
||||||
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **agent:** add PermissionModePlan for interactive read-only planning ([#853](https://github.com/Gitlawb/zero/issues/853)) ([0176ee4](https://github.com/Gitlawb/zero/commit/0176ee49ffddcff6f7edf6ae2ade4967e5b22cc3))
|
|
||||||
* **providers:** live model lists for OpenRouter and OpenGateway ([#860](https://github.com/Gitlawb/zero/issues/860)) ([fb4f293](https://github.com/Gitlawb/zero/commit/fb4f293c20eb860ca388a10d22fd566ce282ba1f))
|
|
||||||
* **tools:** give tool results an image channel and add view_image ([#843](https://github.com/Gitlawb/zero/issues/843)) ([cd0eb19](https://github.com/Gitlawb/zero/commit/cd0eb194e4df4b844785796c0701216740a56344))
|
|
||||||
* **tui:** replace /retitle with local /rename ([#826](https://github.com/Gitlawb/zero/issues/826)) ([5886354](https://github.com/Gitlawb/zero/commit/588635487f22ef4d62d350140a219b7b8bb24119))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **agent:** end the additional_permissions retry loop ([#864](https://github.com/Gitlawb/zero/issues/864)) ([4ca4fa7](https://github.com/Gitlawb/zero/commit/4ca4fa75d31b139dbe55a3badf17da86892ab001))
|
|
||||||
* **agent:** tell the user a prefix approval leaves the sandbox ([#885](https://github.com/Gitlawb/zero/issues/885)) ([2ec5c3a](https://github.com/Gitlawb/zero/commit/2ec5c3ae6b8fb543887bdeafdb34c4da057b8d36))
|
|
||||||
* **cli:** report MCP servers that failed to start ([#822](https://github.com/Gitlawb/zero/issues/822)) ([#827](https://github.com/Gitlawb/zero/issues/827)) ([021281e](https://github.com/Gitlawb/zero/commit/021281ebbab3f2bbe6a77b2dab4bf121bcfe6b52))
|
|
||||||
* **lsp:** dispatch notifications off read loop ([#759](https://github.com/Gitlawb/zero/issues/759)) ([936aa4e](https://github.com/Gitlawb/zero/commit/936aa4e77cf49738560a89395b8ba4d22263a396))
|
|
||||||
* **mcp:** name the non-text blocks a tool result drops ([#874](https://github.com/Gitlawb/zero/issues/874)) ([027a670](https://github.com/Gitlawb/zero/commit/027a67080bea511b876c284bc911ef011eceb308))
|
|
||||||
* **sandbox:** deny reads of Zero credential stores ([#681](https://github.com/Gitlawb/zero/issues/681)) ([55fa05f](https://github.com/Gitlawb/zero/commit/55fa05f0ab5a01b495345d43464606cd19a31b4f))
|
|
||||||
* **sandbox:** stop the Windows write jail honouring Everyone-granted paths ([#865](https://github.com/Gitlawb/zero/issues/865)) ([91b413c](https://github.com/Gitlawb/zero/commit/91b413c514cc1607d5b4e7936780d40569239814))
|
|
||||||
* **secrets,worktrees:** fix secret redaction leakage and prune stale worktrees ([#855](https://github.com/Gitlawb/zero/issues/855)) ([ff62f73](https://github.com/Gitlawb/zero/commit/ff62f73b7f4dc5e2472db21acba43cf6140c06d1))
|
|
||||||
* **specialist:** make overwrites atomic ([#757](https://github.com/Gitlawb/zero/issues/757)) ([30f6d8f](https://github.com/Gitlawb/zero/commit/30f6d8f591a0b81638b4182ff56c86c8a50a5c70))
|
|
||||||
* suppress gosec G204 warning for trusted local config commands ([#848](https://github.com/Gitlawb/zero/issues/848)) ([c52db05](https://github.com/Gitlawb/zero/commit/c52db055b0b213758a33c5c5627a411e052f8de4))
|
|
||||||
* **tui:** a copied NUL byte no longer panics the TUI on Windows ([#876](https://github.com/Gitlawb/zero/issues/876)) ([cae0269](https://github.com/Gitlawb/zero/commit/cae0269e77e7a0a493cf799b41c653c9ff9a19de)), closes [#875](https://github.com/Gitlawb/zero/issues/875)
|
|
||||||
* **tui:** exclude permission waits from turn time ([#820](https://github.com/Gitlawb/zero/issues/820)) ([15eb16f](https://github.com/Gitlawb/zero/commit/15eb16f736f903c94203207032f98aacbcf06318))
|
|
||||||
* **tui:** fall back to CellMotion where AllMotion is unreliable ([#872](https://github.com/Gitlawb/zero/issues/872)) ([7fbd864](https://github.com/Gitlawb/zero/commit/7fbd864e91433441489902571ea394ebc5b7bd0d))
|
|
||||||
* **tui:** fill alt-screen frame with theme panel background ([#850](https://github.com/Gitlawb/zero/issues/850)) ([020c28e](https://github.com/Gitlawb/zero/commit/020c28eaf95e2f38853f99fc06a13cd1568bf3c9))
|
|
||||||
* **tui:** hide unavailable sidebar shortcut ([#821](https://github.com/Gitlawb/zero/issues/821)) ([845df6c](https://github.com/Gitlawb/zero/commit/845df6c7d28e02f475e90acfe838733eb37e6aa2))
|
|
||||||
* use OAuth credentials in provider health checks ([#828](https://github.com/Gitlawb/zero/issues/828)) ([d37de92](https://github.com/Gitlawb/zero/commit/d37de9214b1ba86542f4f5ef3b060bb96849763b))
|
|
||||||
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
* compact common test output ([#846](https://github.com/Gitlawb/zero/issues/846)) ([edf660a](https://github.com/Gitlawb/zero/commit/edf660ab85131f90a452cee51dc2c3a5cf62d632))
|
|
||||||
* improve harness context efficiency ([#838](https://github.com/Gitlawb/zero/issues/838)) ([8e26679](https://github.com/Gitlawb/zero/commit/8e266797a8305e689806eadcf9698da2796481ab))
|
|
||||||
|
|
||||||
## [0.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
|
|
||||||
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
|
|
||||||
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
|
|
||||||
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
|
|
||||||
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
|
|
||||||
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
|
|
||||||
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
|
|
||||||
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
|
|
||||||
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
|
|
||||||
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
|
|
||||||
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
|
|
||||||
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
|
|
||||||
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
|
|
||||||
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
|
|
||||||
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
|
|
||||||
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
|
|
||||||
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
|
|
||||||
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
|
|
||||||
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
|
|
||||||
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
|
|
||||||
|
|
||||||
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
|
|
||||||
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
|
|
||||||
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
|
|
||||||
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
|
|
||||||
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
|
|
||||||
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
|
|
||||||
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
|
|
||||||
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
|
|
||||||
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
|
|
||||||
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
|
|
||||||
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
|
|
||||||
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
|
|
||||||
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
|
|
||||||
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
|
|
||||||
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
|
|
||||||
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
|
|
||||||
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
|
|
||||||
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
|
|
||||||
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
|
|
||||||
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
|
|
||||||
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
|
|
||||||
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
|
|
||||||
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
|
|
||||||
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
|
|
||||||
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
|
|
||||||
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
|
|
||||||
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
|
|
||||||
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
|
|
||||||
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
|
|
||||||
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
|
|
||||||
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
|
|
||||||
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
|
|
||||||
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
|
|
||||||
|
|
||||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,28 +67,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
|
It does not approve unrelated future work, future pull requests, or future issues
|
||||||
from the same author. Each issue is reviewed case by case.
|
from the same author. Each issue is reviewed case by case.
|
||||||
|
|
||||||
## Contribution Volume and Batching
|
|
||||||
|
|
||||||
To keep triage and review sustainable while the project stabilizes, please keep
|
|
||||||
your contribution flow to a size the core team can realistically work through:
|
|
||||||
|
|
||||||
- One issue per bug. File each defect as its own issue with its own
|
|
||||||
reproduction. Please do not bundle several unrelated findings into a single
|
|
||||||
"audit" issue; separate issues are far easier to triage, approve, and close
|
|
||||||
individually.
|
|
||||||
- Keep only a few items open at a time. If you already have several issues or
|
|
||||||
pull requests awaiting review, let those move through the queue before opening
|
|
||||||
more. A large simultaneous burst from one contributor slows review for
|
|
||||||
everyone, even when each item is valid.
|
|
||||||
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
|
||||||
and generated patches are fine, but each issue and pull request must be
|
|
||||||
individually verified, scoped, and reproducible before you open it. Please do
|
|
||||||
not post bulk machine-generated findings unfiltered.
|
|
||||||
|
|
||||||
These apply to all contributors and are about review bandwidth, not the value of
|
|
||||||
any single report. Maintainers may ask you to consolidate, split, or hold
|
|
||||||
submissions to keep the queue manageable.
|
|
||||||
|
|
||||||
## Pull Request Requirements
|
## Pull Request Requirements
|
||||||
|
|
||||||
Before opening a community pull request, make sure all of the following are true:
|
Before opening a community pull request, make sure all of the following are true:
|
||||||
@@ -126,31 +104,6 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
|
|||||||
specific bug, addressing a security issue, improving compatibility, or supporting
|
specific bug, addressing a security issue, improving compatibility, or supporting
|
||||||
an approved feature.
|
an approved feature.
|
||||||
|
|
||||||
## Supported platforms
|
|
||||||
|
|
||||||
Zero publishes five native prebuilt artifacts from
|
|
||||||
`.github/workflows/release-artifacts.yml`:
|
|
||||||
|
|
||||||
- `linux-x64` (`linux/amd64`)
|
|
||||||
- `linux-arm64` (`linux/arm64`)
|
|
||||||
- `macos-arm64` (`darwin/arm64`)
|
|
||||||
- `macos-x64` (`darwin/amd64`)
|
|
||||||
- `windows-x64` (`windows/amd64`)
|
|
||||||
|
|
||||||
The shell installer and npm wrapper resolve the applicable artifacts from that
|
|
||||||
list. The PowerShell installer supports the published `windows-x64` artifact;
|
|
||||||
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
|
|
||||||
build under emulation or build from source.
|
|
||||||
|
|
||||||
Android/Termux is also supported even though it has no distinct release
|
|
||||||
artifact. The npm wrapper maps Android to the Linux artifact, and
|
|
||||||
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
|
|
||||||
devices whose seccomp policy blocks the Linux build's syscall path.
|
|
||||||
|
|
||||||
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
|
|
||||||
review criterion. Do not block a pull request for failing to compile on an
|
|
||||||
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
|
|
||||||
|
|
||||||
## What We Prioritize
|
## What We Prioritize
|
||||||
|
|
||||||
The core team will prioritize:
|
The core team will prioritize:
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
|
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||||
# with `make` — these targets back those instructions.
|
# lint` before opening a PR" — these targets back those instructions.
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
|
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
|
||||||
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 vulncheck-windows tidy clean baseline help
|
|
||||||
|
|
||||||
# Build the main CLI binary into ./zero.
|
# Build the main CLI binary into ./zero.
|
||||||
build:
|
build:
|
||||||
@@ -39,28 +33,6 @@ fmt-check:
|
|||||||
# Lint = formatting check + vet (no extra tooling required).
|
# Lint = formatting check + vet (no extra tooling required).
|
||||||
lint: fmt-check vet
|
lint: fmt-check vet
|
||||||
|
|
||||||
# Versioned tools select the toolchain from their own modules when invoked with
|
|
||||||
# package@version. Read this module's go directive directly, without invoking
|
|
||||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
|
||||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
|
||||||
# The target-specific export is shell-independent.
|
|
||||||
lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
|
||||||
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
|
|
||||||
|
|
||||||
lint-static:
|
|
||||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
|
||||||
|
|
||||||
deadcode:
|
|
||||||
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
|
|
||||||
|
|
||||||
vulncheck:
|
|
||||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
|
||||||
|
|
||||||
vulncheck-windows:
|
|
||||||
mkdir -p "$(CURDIR)/.cache/gobin"
|
|
||||||
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
|
||||||
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
|
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
@@ -83,4 +55,4 @@ baseline: build
|
|||||||
--output internal/perfbench/reports/baseline.json
|
--output internal/perfbench/reports/baseline.json
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
|
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
|
||||||
@@ -6,9 +6,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
<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">
|
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
|
||||||
<br>
|
<br>
|
||||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -26,7 +25,7 @@ zero exec --output-format stream-json < turns.jsonl
|
|||||||
## Why Zero
|
## Why Zero
|
||||||
|
|
||||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
|
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||||
OpenAI-/Anthropic-compatible endpoint.
|
OpenAI-/Anthropic-compatible endpoint.
|
||||||
- **Stay in control.** File writes, shell commands, network access, and
|
- **Stay in control.** File writes, shell commands, network access, and
|
||||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||||
@@ -76,7 +75,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
|||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
Source builds require Go 1.26.6+.
|
Source builds require Go 1.26.5+.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Gitlawb/zero.git
|
git clone https://github.com/Gitlawb/zero.git
|
||||||
@@ -132,7 +131,6 @@ export ANTHROPIC_API_KEY=...
|
|||||||
export GEMINI_API_KEY=...
|
export GEMINI_API_KEY=...
|
||||||
export AIMLAPI_API_KEY=...
|
export AIMLAPI_API_KEY=...
|
||||||
export LONGCAT_API_KEY=...
|
export LONGCAT_API_KEY=...
|
||||||
export FIREWORKS_API_KEY=...
|
|
||||||
export MINIMAX_API_KEY=...
|
export MINIMAX_API_KEY=...
|
||||||
export MINIMAXI_API_KEY=...
|
export MINIMAXI_API_KEY=...
|
||||||
```
|
```
|
||||||
@@ -149,12 +147,6 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
|
|||||||
zero providers setup longcat --set-active
|
zero providers setup longcat --set-active
|
||||||
```
|
```
|
||||||
|
|
||||||
To configure Fireworks AI directly, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
zero providers setup fireworks --set-active
|
|
||||||
```
|
|
||||||
|
|
||||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||||
regions:
|
regions:
|
||||||
|
|
||||||
@@ -182,12 +174,8 @@ zero providers add custom-openai-compatible \
|
|||||||
--set-active
|
--set-active
|
||||||
```
|
```
|
||||||
|
|
||||||
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
|
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||||
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
|
`zero providers detect`.
|
||||||
load a model and enable its local OpenAI-compatible API (default
|
|
||||||
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
|
|
||||||
loaded model ID in the add command. If no usable ID is discovered, load a model
|
|
||||||
and retry. Model IDs requiring shell-specific quoting use interactive setup.
|
|
||||||
|
|
||||||
## Daily Use
|
## Daily Use
|
||||||
|
|
||||||
@@ -207,7 +195,7 @@ Useful controls:
|
|||||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||||
| `Shift+Tab` | cycle permission mode |
|
| `Shift+Tab` | cycle permission mode |
|
||||||
| `Ctrl+B` | show/hide the sidebar |
|
| `Ctrl+B` | show/hide the sidebar |
|
||||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
| `Ctrl+C` | cancel or exit |
|
||||||
|
|
||||||
Common slash commands:
|
Common slash commands:
|
||||||
|
|
||||||
@@ -217,8 +205,6 @@ Common slash commands:
|
|||||||
| `/spec`, `/plan` | draft and review a plan before building |
|
| `/spec`, `/plan` | draft and review a plan before building |
|
||||||
| `/image` | attach an image for vision-capable models |
|
| `/image` | attach an image for vision-capable models |
|
||||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||||
| `/new` | start a fresh session in place (previous session stays on disk) |
|
|
||||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
|
||||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||||
| `/compact`, `/context` | manage context usage |
|
| `/compact`, `/context` | manage context usage |
|
||||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||||
@@ -277,19 +263,6 @@ zero sandbox grants list
|
|||||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||||
and MCP support for additional tools.
|
and MCP support for additional tools.
|
||||||
|
|
||||||
`web_fetch` refuses loopback, private and other special-use addresses: it checks
|
|
||||||
the URL before asking permission, resolves the host, and dials the address it
|
|
||||||
validated so a name cannot resolve to something else in between.
|
|
||||||
|
|
||||||
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
|
|
||||||
probe use it, and that last step changes: the proxy is dialed and the target
|
|
||||||
hostname is sent to it, so the proxy decides which address the request actually
|
|
||||||
reaches. The URL is still checked and resolved locally first, but a proxy that
|
|
||||||
answers differently can reach a private service. A forward proxy already sees
|
|
||||||
and can rewrite every request through it, so this is the trust you accept by
|
|
||||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
|
||||||
end.
|
|
||||||
|
|
||||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
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
|
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
|
attached to a background terminal session and can be listed or stopped from the
|
||||||
@@ -327,8 +300,7 @@ zero verify detect and run local verification checks
|
|||||||
zero changes inspect and commit local git changes
|
zero changes inspect and commit local git changes
|
||||||
zero usage token usage and estimated cost
|
zero usage token usage and estimated cost
|
||||||
zero cron scheduled agent jobs
|
zero cron scheduled agent jobs
|
||||||
zero update --check check for newer releases
|
zero update check for newer releases
|
||||||
zero upgrade download, verify, and install the latest release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending Zero
|
## Extending Zero
|
||||||
@@ -388,34 +360,27 @@ go run ./cmd/zero-perf-bench
|
|||||||
|
|
||||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||||
session (background connection prewarm + request-prefix telemetry) for headless
|
session (background connection prewarm + request-prefix telemetry) for headless
|
||||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
|
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
||||||
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
|
||||||
with the variable unset and set.
|
variable unset and set.
|
||||||
|
|
||||||
Native ChatGPT Responses sessions are enabled by default. Set
|
|
||||||
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
|
|
||||||
transport.
|
|
||||||
|
|
||||||
### Code Quality and Security Checks
|
### Code Quality and Security Checks
|
||||||
|
|
||||||
Before committing any changes, ensure all Go code quality and security checks pass. 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.
|
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
|
||||||
|
|
||||||
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
|
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
||||||
2. **Vetting**: Run `make vet` (or `go vet ./...`).
|
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
||||||
3. **Linting**: Run `make lint-static`.
|
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||||
4. **Vulnerability Scan**: Run `make vulncheck`.
|
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||||
|
|
||||||
Use the repository-managed targets rather than globally installed binaries:
|
If you prefer to install these tools globally on your path, you can run:
|
||||||
the targets apply the module's required Go toolchain as well as the reviewed,
|
|
||||||
pinned tool versions.
|
|
||||||
|
|
||||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
|
||||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
|
||||||
run them directly. If it isn't, add it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
# Install golangci-lint
|
||||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||||
|
|
||||||
|
# Install govulncheck
|
||||||
|
go install golang.org/x/vuln/cmd/[email protected]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cross-Compile Examples
|
### Cross-Compile Examples
|
||||||
@@ -439,8 +404,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
|||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
|
|
||||||
|
|
||||||
Questions, setup help, ideas, and sharing all live in
|
Questions, setup help, ideas, and sharing all live in
|
||||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||||
|
|
||||||
|
|||||||
+5
-26
@@ -6,9 +6,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
<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">
|
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
|
||||||
<br>
|
<br>
|
||||||
<a href="README.md">English</a> | <strong>中文</strong>
|
<a href="README.md">English</a> | <strong>中文</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -23,7 +22,7 @@ zero exec --output-format stream-json < turns.jsonl
|
|||||||
|
|
||||||
## 为什么选择 Zero
|
## 为什么选择 Zero
|
||||||
|
|
||||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||||
@@ -57,7 +56,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
|||||||
|
|
||||||
### 从源码构建
|
### 从源码构建
|
||||||
|
|
||||||
源码构建需要 Go 1.26.6+。
|
源码构建需要 Go 1.26.5+。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Gitlawb/zero.git
|
git clone https://github.com/Gitlawb/zero.git
|
||||||
@@ -114,11 +113,7 @@ export LONGCAT_API_KEY=...
|
|||||||
zero providers setup longcat --set-active
|
zero providers setup longcat --set-active
|
||||||
```
|
```
|
||||||
|
|
||||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
|
||||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
|
||||||
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
|
|
||||||
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
|
|
||||||
|
|
||||||
## 日常使用
|
## 日常使用
|
||||||
|
|
||||||
@@ -146,7 +141,6 @@ zero
|
|||||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||||
| `/image` | 为视觉模型附加图片 |
|
| `/image` | 为视觉模型附加图片 |
|
||||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||||
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
|
|
||||||
| `/compact`、`/context` | 管理上下文使用 |
|
| `/compact`、`/context` | 管理上下文使用 |
|
||||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||||
@@ -232,8 +226,7 @@ zero verify 检测和运行本地验证检查
|
|||||||
zero changes 检查和提交本地 Git 变更
|
zero changes 检查和提交本地 Git 变更
|
||||||
zero usage Token 使用量和估算成本
|
zero usage Token 使用量和估算成本
|
||||||
zero cron 定时智能体任务
|
zero cron 定时智能体任务
|
||||||
zero update --check 检查更新版本
|
zero update 检查更新版本
|
||||||
zero upgrade 下载、验证并安装最新版本
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 外观和无障碍
|
## 外观和无障碍
|
||||||
@@ -276,20 +269,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
|||||||
- [性能](docs/PERFORMANCE.md)
|
- [性能](docs/PERFORMANCE.md)
|
||||||
- [智能体评估](docs/AGENT_EVALS.md)
|
- [智能体评估](docs/AGENT_EVALS.md)
|
||||||
|
|
||||||
## 社区
|
|
||||||
|
|
||||||
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
|
|
||||||
|
|
||||||
提问、安装帮助、想法和分享都在
|
|
||||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
|
||||||
|
|
||||||
| 分类 | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
|
|
||||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
|
|
||||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
|
|
||||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
|
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||||
|
|||||||
+1
-1
@@ -33,4 +33,4 @@ Please include, where possible:
|
|||||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||||
out of scope — but please report anything you are unsure about.
|
out of scope — but please report anything you are unsure about.
|
||||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
|
keyless web search, to the documented Firecrawl endpoint — see the README).
|
||||||
+2
-3
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
|
|||||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||||
|
|
||||||
A missing directory is fine — Zero just omits it. Management commands (`zero 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.
|
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
~/.local/share/zero/skills/ # primary (install/remove/lock target)
|
||||||
run-benchmarks/
|
run-benchmarks/
|
||||||
SKILL.md
|
SKILL.md
|
||||||
write-changelog/
|
write-changelog/
|
||||||
@@ -289,7 +289,6 @@ Install and manage:
|
|||||||
```bash
|
```bash
|
||||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||||
zero plugins list
|
zero plugins list
|
||||||
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
|
||||||
zero plugins remove github-pr-review # alias: rm
|
zero plugins remove github-pr-review # alias: rm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -302,9 +302,8 @@ Conceptually, one requested tool goes through these phases:
|
|||||||
the model can inspect it and decide what to do next.
|
the model can inspect it and decide what to do next.
|
||||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
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
|
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||||
resume/history; ACP persists conversational messages and completed tool-call
|
resume/history; ACP currently persists conversational messages and streams
|
||||||
starts/results for load replay. Live streaming details and permission
|
tool/permission updates to the client.
|
||||||
exchanges remain client notifications rather than durable history.
|
|
||||||
|
|
||||||
Tool success and failure are both informative context. A successful `read_file`
|
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
|
result gives the model file contents; a failed `edit_file` result gives the model
|
||||||
@@ -628,12 +627,7 @@ Core tool groups include:
|
|||||||
skills, `ask_user`, permission requests.
|
skills, `ask_user`, permission requests.
|
||||||
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
||||||
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
||||||
- **Network tools**: `web_fetch` and optional web search backend. `web_fetch`
|
- **Network tools**: `web_fetch` and optional web search backend.
|
||||||
validates the URL before the permission prompt, refuses loopback/private/
|
|
||||||
special-use addresses, and dials the address it resolved. With
|
|
||||||
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
|
|
||||||
target itself, so destination enforcement ends at the proxy; see the note in
|
|
||||||
the README.
|
|
||||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||||
`internal/browser`.
|
`internal/browser`.
|
||||||
@@ -755,7 +749,7 @@ flowchart TD
|
|||||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
|
||||||
- **Hooks** can observe or block tool lifecycle events.
|
- **Hooks** can observe or block tool lifecycle events.
|
||||||
|
|
||||||
## End-to-End Data Flow
|
## End-to-End Data Flow
|
||||||
|
|||||||
+10
-86
@@ -1,30 +1,14 @@
|
|||||||
# Installing Zero
|
# Installing Zero
|
||||||
|
|
||||||
Pick whichever fits how you already install things. None of these is the
|
Zero is distributed as:
|
||||||
blessed one.
|
|
||||||
|
|
||||||
| Method | Command | Self-update |
|
- an npm package, `@gitlawb/zero`
|
||||||
| --- | --- | --- |
|
- release archives on GitHub Releases
|
||||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
- source builds with Go 1.26.5+
|
||||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
|
||||||
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
|
|
||||||
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
|
|
||||||
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
|
|
||||||
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
|
|
||||||
| Source | `go build -o zero ./cmd/zero` | rebuild |
|
|
||||||
|
|
||||||
`zero upgrade` follows whatever owns the binary, with one exception it cannot
|
The install scripts download a platform-specific release archive and require a
|
||||||
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
|
published GitHub Release for the requested version. The npm package is
|
||||||
keg and names `brew upgrade zero`, and replaces the binary in place for
|
self-contained: the platform binary installs from the npm registry.
|
||||||
everything else — including a mise-managed one, which is the exception.
|
|
||||||
|
|
||||||
mise is the one case it cannot detect: a mise-managed binary is an ordinary
|
|
||||||
standalone install on disk, so `zero upgrade` will replace it and leave mise
|
|
||||||
describing a version that is no longer there. Use `mise upgrade` instead.
|
|
||||||
|
|
||||||
Release archives are the substrate for most of the above: the install scripts
|
|
||||||
and the npm fallback both download a platform archive from a published GitHub
|
|
||||||
Release and verify its checksum.
|
|
||||||
|
|
||||||
## npm
|
## npm
|
||||||
|
|
||||||
@@ -127,45 +111,6 @@ Defaults:
|
|||||||
- Version: latest GitHub release
|
- Version: latest GitHub release
|
||||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||||
|
|
||||||
## mise
|
|
||||||
|
|
||||||
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
|
|
||||||
archives through its `ubi` backend, so there is nothing extra to publish and no
|
|
||||||
registry in the middle:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise use -g ubi:Gitlawb/zero
|
|
||||||
```
|
|
||||||
|
|
||||||
Pin a version the same way you would any other tool:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise use -g ubi:Gitlawb/[email protected]
|
|
||||||
```
|
|
||||||
|
|
||||||
Updates come from `mise upgrade`. `zero upgrade` also works, because a
|
|
||||||
mise-managed binary is an ordinary standalone install, but then mise's records
|
|
||||||
describe a version that is no longer on disk. Prefer `mise upgrade`.
|
|
||||||
|
|
||||||
## go install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
|
||||||
release archives. Two consequences worth knowing before you pick it:
|
|
||||||
|
|
||||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
|
||||||
not installed by this command. See
|
|
||||||
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
|
|
||||||
Without it, native sandboxing is unavailable.
|
|
||||||
- `zero upgrade` treats the result as a standalone install and will replace the
|
|
||||||
binary with a release build rather than rebuilding from source. If you chose
|
|
||||||
`go install` deliberately, rerun it instead.
|
|
||||||
|
|
||||||
macOS and Windows need no extra helper.
|
|
||||||
|
|
||||||
## From Source
|
## From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -180,7 +125,7 @@ Build a local binary:
|
|||||||
go build -o zero ./cmd/zero
|
go build -o zero ./cmd/zero
|
||||||
```
|
```
|
||||||
|
|
||||||
Source builds require Go 1.26.6+.
|
Source builds require Go 1.26.5+.
|
||||||
|
|
||||||
### Sandbox Helpers For Source Builds
|
### Sandbox Helpers For Source Builds
|
||||||
|
|
||||||
@@ -289,31 +234,10 @@ directory.
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
Check for a newer release, then download, verify, and install it in place:
|
Check for a newer release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
zero update --check
|
zero update --check
|
||||||
zero upgrade
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
Then reinstall with npm or rerun the install script for the version you want.
|
||||||
|
|
||||||
`zero upgrade` behaves differently depending on how Zero was installed, because
|
|
||||||
overwriting a binary a package manager owns leaves that manager describing a
|
|
||||||
version that is no longer there:
|
|
||||||
|
|
||||||
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
|
|
||||||
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
|
|
||||||
keg binary directly would be reverted by the next `brew upgrade` or
|
|
||||||
`brew reinstall`.
|
|
||||||
- **everything else**: downloads the verified release archive and replaces the
|
|
||||||
binary in place.
|
|
||||||
|
|
||||||
Homebrew is detected by the binary living inside a Cellar keg that carries
|
|
||||||
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
|
|
||||||
directory tree you built yourself can have exactly that shape, and refusing to
|
|
||||||
update an install Homebrew never touched would be a real cost for a made-up
|
|
||||||
reason. The receipt is the thing only Homebrew writes.
|
|
||||||
|
|
||||||
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
|
|
||||||
where that is also the Homebrew prefix.
|
|
||||||
@@ -138,45 +138,3 @@ output or stop a still-running task by id.
|
|||||||
If Zero is restarted while a background task is still marked `running`, the new
|
If Zero is restarted while a background task is still marked `running`, the new
|
||||||
manager marks that task `error` and clears its PID. This avoids sending
|
manager marks that task `error` and clears its PID. This avoids sending
|
||||||
`TaskStop` to a stale PID that may now belong to an unrelated process.
|
`TaskStop` to a stale PID that may now belong to an unrelated process.
|
||||||
|
|
||||||
## Recovering an Interrupted Overwrite
|
|
||||||
|
|
||||||
Zero writes and flushes a complete temporary file before publishing an overwrite,
|
|
||||||
so a write failure before publication leaves the existing manifest unchanged
|
|
||||||
instead of truncating it. On Unix, publication uses a same-directory rename and
|
|
||||||
preserves the existing file's permission bits.
|
|
||||||
|
|
||||||
On Windows, Zero uses `ReplaceFileW` to preserve the destination DACL instead of
|
|
||||||
silently replacing it with the temporary file's inherited DACL. `ReplaceFileW`
|
|
||||||
is not observer-atomic: another process can briefly observe the destination path
|
|
||||||
as absent during replacement. Zero serializes specialist loads and managed
|
|
||||||
mutations within one process, but cannot synchronize external processes or
|
|
||||||
editors.
|
|
||||||
|
|
||||||
Windows errors 1176 (`ERROR_UNABLE_TO_MOVE_REPLACEMENT`) and 1177
|
|
||||||
(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) are partial replacement failures. With
|
|
||||||
Zero's managed backup, 1176 leaves the original names intact and needs no manual
|
|
||||||
recovery. For 1177, Zero has moved the original aside and tries to move it back.
|
|
||||||
That rollback almost always succeeds, and the failed write changes nothing.
|
|
||||||
|
|
||||||
If the rollback itself fails — typically because another process is holding a
|
|
||||||
lock on the file — the original is not lost, but it is left under a name Zero
|
|
||||||
does not read:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<specialist dir>/.zero-replace-<random>.backup
|
|
||||||
```
|
|
||||||
|
|
||||||
Only `*.md` files are loaded as specialists, so until that file is renamed the
|
|
||||||
specialist will not appear in `zero specialist list` or resolve by name. Zero's
|
|
||||||
error message includes both the backup path and destination path. Recover by
|
|
||||||
closing whatever holds the lock and renaming the backup back:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Move-Item .zero-replace-<random>.backup <name>.md
|
|
||||||
```
|
|
||||||
|
|
||||||
A `.zero-replace-*.backup` can also linger after a *successful* overwrite if the
|
|
||||||
backup could not be deleted afterward. That case is reported as a warning rather
|
|
||||||
than an error — the new manifest is already in place, and the leftover file is
|
|
||||||
safe to delete.
|
|
||||||
+7
-85
@@ -1,23 +1,16 @@
|
|||||||
# Update Flow
|
# Update Flow
|
||||||
|
|
||||||
`zero update --check` checks the latest GitHub release and compares it with the
|
`zero update --check` checks the latest GitHub release and compares it with the
|
||||||
local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`)
|
local CLI version.
|
||||||
downloads, verifies, and installs it.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
zero update --check
|
zero update --check
|
||||||
zero update --check --json
|
zero update --check --json
|
||||||
zero update --check --repo Gitlawb/zero
|
zero update --check --repo Gitlawb/zero
|
||||||
zero update --check --target windows-x64
|
zero update --check --target windows-x64
|
||||||
|
|
||||||
zero upgrade
|
|
||||||
zero update --apply
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
|
The command is intentionally check-only:
|
||||||
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
|
||||||
|
|
||||||
`--check` is check-only:
|
|
||||||
|
|
||||||
- It does not replace the running binary.
|
- It does not replace the running binary.
|
||||||
- It exits with code `0` when the check succeeds, even when an update is
|
- It exits with code `0` when the check succeeds, even when an update is
|
||||||
@@ -26,35 +19,14 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
|||||||
- `--json` prints the same result in a machine-readable format for scripts and
|
- `--json` prints the same result in a machine-readable format for scripts and
|
||||||
CI.
|
CI.
|
||||||
|
|
||||||
`--apply` installs the update in place:
|
|
||||||
|
|
||||||
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
|
|
||||||
- Standalone installs download the release archive, verify its checksum,
|
|
||||||
extract it, and atomically replace the running binary plus any installed
|
|
||||||
optional sandbox helpers.
|
|
||||||
- On Windows, the running executable is renamed aside and cleaned up on the
|
|
||||||
next `zero update --apply` or `zero upgrade`, since it can't be overwritten
|
|
||||||
while running.
|
|
||||||
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
|
|
||||||
since applying always installs onto the current machine.
|
|
||||||
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
|
|
||||||
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
|
|
||||||
takes its release from the npm registry, not from GitHub. They still apply to
|
|
||||||
`--check` there.
|
|
||||||
- `--json` serializes Zero's final result. For npm-managed installs, npm may
|
|
||||||
also write progress output to stdout, so neither
|
|
||||||
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
|
|
||||||
produce a single parseable JSON document. Use `--check --json` for
|
|
||||||
machine-readable automation.
|
|
||||||
|
|
||||||
Useful flags:
|
Useful flags:
|
||||||
|
|
||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
| `--repo <owner/repo>` | Check another GitHub repository. |
|
||||||
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
||||||
| `--timeout <duration>` | Override the default release check timeout. |
|
| `--timeout <duration>` | Override the default release check timeout. |
|
||||||
| `--target <platform-arch>` | Validate release metadata for another supported target (`--check` only). |
|
| `--target <platform-arch>` | Validate release metadata for another supported target. |
|
||||||
|
|
||||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
||||||
@@ -68,55 +40,5 @@ Endpoint resolution order:
|
|||||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||||
|
|
||||||
Installer scripts download the matching release asset for the local platform and
|
Installer scripts download the matching release asset for the local platform and
|
||||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
|
||||||
instead of reinstalling.
|
before reinstalling.
|
||||||
|
|
||||||
## Windows recovery state (standalone installs)
|
|
||||||
|
|
||||||
This section describes Windows only. On Linux and macOS a standalone update
|
|
||||||
renames the staged file directly over the executable path through the
|
|
||||||
installation directory's file descriptor, so the replacement is atomic and no
|
|
||||||
aside copy, marker, or recovery record is ever created — a failed update leaves
|
|
||||||
the previous binary in place and nothing to resolve.
|
|
||||||
|
|
||||||
On Windows, a running executable cannot be replaced in place, so the previous
|
|
||||||
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
|
|
||||||
records that exact file — bound to its filesystem identity, in per-user state
|
|
||||||
outside the installation directory — and deletes only that recorded copy after
|
|
||||||
the next update is verified in place. Backups it did not create are never
|
|
||||||
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
|
|
||||||
recorded copy that something else holds open (an on-access scanner, an editor)
|
|
||||||
stays recorded and is removed by a later update instead.
|
|
||||||
|
|
||||||
An update **refuses to run** while unresolved recovery state exists beside the
|
|
||||||
binary:
|
|
||||||
|
|
||||||
| State on disk | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
|
|
||||||
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
|
|
||||||
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
|
|
||||||
|
|
||||||
The refusal names the paths involved and the two moves that end the state:
|
|
||||||
either move the recovery binary back over the executable path, or — if the
|
|
||||||
installed binary is the one you want — delete the recovery copy itself (not
|
|
||||||
only the `.keep` marker) after verifying the installed binary, then update
|
|
||||||
again. Deleting the marker alone is not enough: a failed restore also records
|
|
||||||
that copy in per-user state outside the installation directory, and the next
|
|
||||||
update consults that record even if the marker is gone.
|
|
||||||
|
|
||||||
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
|
|
||||||
The recovery copy and its `.keep` marker sit in the installation directory
|
|
||||||
(per-machine). A second Windows account on the same machine does not see the
|
|
||||||
first account's unresolved record; the sibling marker is the machine-visible
|
|
||||||
signal for every account. There is no separate machine-wide store: writing one
|
|
||||||
would need a shared writable location and ACL rules the updater does not own.
|
|
||||||
|
|
||||||
This is deliberately fail-closed and differs from older releases, which deleted
|
|
||||||
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
|
|
||||||
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
|
|
||||||
and make every subsequent update refuse until an operator clears them. That is
|
|
||||||
the safe direction: the alternative is an update that overwrites the only
|
|
||||||
verified copy of the previous binary. If updates start refusing, inspect those
|
|
||||||
files before removing them — an installation directory that a lower-privileged
|
|
||||||
account can write to is itself worth fixing.
|
|
||||||
@@ -1,28 +1,25 @@
|
|||||||
module github.com/Gitlawb/zero
|
module github.com/Gitlawb/zero
|
||||||
|
|
||||||
go 1.26.6
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.1.1
|
charm.land/bubbles/v2 v2.1.1
|
||||||
charm.land/bubbletea/v2 v2.0.9
|
charm.land/bubbletea/v2 v2.0.8
|
||||||
charm.land/lipgloss/v2 v2.0.5
|
charm.land/lipgloss/v2 v2.0.5
|
||||||
github.com/Microsoft/go-winio v0.6.2
|
|
||||||
github.com/alecthomas/chroma/v2 v2.27.0
|
github.com/alecthomas/chroma/v2 v2.27.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1
|
github.com/aymanbagabas/go-udiff v0.4.1
|
||||||
github.com/charmbracelet/colorprofile v0.4.3
|
github.com/charmbracelet/colorprofile v0.4.3
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.7
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||||
golang.org/x/image v0.45.0
|
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
mvdan.cc/sh/v3 v3.13.1
|
mvdan.cc/sh/v3 v3.13.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
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 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||||
@@ -64,15 +62,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||||
+89
-802
File diff suppressed because it is too large.
Load diff
+13
-2101
File diff suppressed because it is too large.
Load diff
@@ -1,217 +0,0 @@
|
|||||||
package acp
|
|
||||||
|
|
||||||
// desktopinterop_test.go covers three defects found by driving the real `zero
|
|
||||||
// acp` binary from a desktop ACP client. Each is a case where ZERO and a
|
|
||||||
// conforming client disagreed about the wire, and none of them failed loudly —
|
|
||||||
// they produced a silent denial, a spurious crash, or two buttons the user
|
|
||||||
// could not tell apart.
|
|
||||||
//
|
|
||||||
// A fourth, session/load restoring history without replaying it, is fixed by
|
|
||||||
// #914 rather than here: that one also gives the replayed messages stable ids
|
|
||||||
// and keeps session/resume replay-free, which this did not.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- an offered option is an acceptable answer ----
|
|
||||||
|
|
||||||
// The list the options were BUILT from and the list the answer was CHECKED
|
|
||||||
// against were different whenever ZERO did not enumerate: the client was sent
|
|
||||||
// Allow and Reject, and its reply was validated against an empty slice, so
|
|
||||||
// every button it could possibly show failed closed to deny. The user clicked
|
|
||||||
// Allow and ZERO recorded a denial, with nothing on screen to say so.
|
|
||||||
func TestAnOfferedOptionIsAccepted(t *testing.T) {
|
|
||||||
// No AvailableDecisions: the fallback path, which is every permission event
|
|
||||||
// that is not a prompt.
|
|
||||||
req := agent.PermissionRequest{ToolName: "bash"}
|
|
||||||
|
|
||||||
options := buildPermissionOptions(req)
|
|
||||||
if len(options) == 0 {
|
|
||||||
t.Fatal("no options were offered")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, option := range options {
|
|
||||||
decision := decisionFromOutcome(
|
|
||||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
|
||||||
buildPermissionOptions(req),
|
|
||||||
)
|
|
||||||
if string(decision.Action) != option.OptionID {
|
|
||||||
t.Fatalf("option %q was answered with %q (%s) — an offered option must be accepted",
|
|
||||||
option.OptionID, decision.Action, decision.Reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The narrowing this validation exists for still holds: a client must not be
|
|
||||||
// able to return a broader grant than it was shown.
|
|
||||||
func TestAnOptionThatWasNotOfferedIsStillRefused(t *testing.T) {
|
|
||||||
req := agent.PermissionRequest{ToolName: "bash"}
|
|
||||||
|
|
||||||
decision := decisionFromOutcome(
|
|
||||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAlwaysAllow)},
|
|
||||||
buildPermissionOptions(req),
|
|
||||||
)
|
|
||||||
if decision.Action != agent.PermissionDecisionDeny {
|
|
||||||
t.Fatalf("an unoffered broader grant was accepted as %q", decision.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The resolver is the single source of truth for both sides, which is what
|
|
||||||
// makes the two agree by construction rather than by being kept in step.
|
|
||||||
func TestOfferedDecisionsMatchWhatWasSent(t *testing.T) {
|
|
||||||
for _, req := range []agent.PermissionRequest{
|
|
||||||
{ToolName: "bash"},
|
|
||||||
{ToolName: "bash", AvailableDecisions: []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionAllowForSession,
|
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
}},
|
|
||||||
} {
|
|
||||||
offered := buildPermissionOptions(req)
|
|
||||||
for _, option := range buildPermissionOptions(req) {
|
|
||||||
if !actionOffered(option.OptionID, offered) {
|
|
||||||
t.Fatalf("option %q was sent but is not in the offered set %v", option.OptionID, offered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- two options the user can tell apart ----
|
|
||||||
|
|
||||||
// request_permissions offers plain allow and strict-review allow together, and
|
|
||||||
// both were labelled "Allow". The label is the only thing an ACP client shows,
|
|
||||||
// so the panel presented two identical buttons where one silently enabled
|
|
||||||
// strict auto-review of what was granted.
|
|
||||||
func TestEveryOfferedOptionHasADistinctLabel(t *testing.T) {
|
|
||||||
req := agent.PermissionRequest{
|
|
||||||
ToolName: "request_permissions",
|
|
||||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionAllowStrict,
|
|
||||||
agent.PermissionDecisionAllowForSession,
|
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := map[string]string{}
|
|
||||||
for _, option := range buildPermissionOptions(req) {
|
|
||||||
if previous, clash := seen[option.Name]; clash {
|
|
||||||
t.Fatalf("options %q and %q share the label %q — a user cannot tell them apart",
|
|
||||||
previous, option.OptionID, option.Name)
|
|
||||||
}
|
|
||||||
seen[option.Name] = option.OptionID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two remain distinct decisions: the label changed, the round trip did not.
|
|
||||||
func TestStrictAllowStillRoundTrips(t *testing.T) {
|
|
||||||
req := agent.PermissionRequest{
|
|
||||||
ToolName: "request_permissions",
|
|
||||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionAllowStrict,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
decision := decisionFromOutcome(
|
|
||||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAllowStrict)},
|
|
||||||
buildPermissionOptions(req),
|
|
||||||
)
|
|
||||||
if decision.Action != agent.PermissionDecisionAllowStrict {
|
|
||||||
t.Fatalf("strict allow round-tripped as %q", decision.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- cancelling a permission is a cancellation, not a crash ----
|
|
||||||
|
|
||||||
// Only context.Canceled was recognised, so dismissing a permission dialog came
|
|
||||||
// back as JSON-RPC -32603 carrying the internal sentinel text. Clients render
|
|
||||||
// that as a failed turn, so declining a tool looked like ZERO falling over —
|
|
||||||
// and for apply_patch, dismissing is the ONLY refusal a client is offered.
|
|
||||||
func TestCancellingAPermissionEndsTheTurnAsCancelled(t *testing.T) {
|
|
||||||
canceled := fmt.Errorf("%w for apply_patch", agent.ErrPermissionApprovalCanceled)
|
|
||||||
|
|
||||||
reason, err := stopReasonFor(agent.Result{}, canceled)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stopReasonFor returned an error for a cancellation: %v", err)
|
|
||||||
}
|
|
||||||
if reason != StopCancelled {
|
|
||||||
t.Fatalf("stop reason = %q, want %q", reason, StopCancelled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A genuine failure must still be an error. Mapping too much to cancelled would
|
|
||||||
// hide real faults behind a clean ending, which is the opposite mistake.
|
|
||||||
func TestARealFailureIsStillAnError(t *testing.T) {
|
|
||||||
failure := errors.New("provider exploded")
|
|
||||||
|
|
||||||
if _, err := stopReasonFor(agent.Result{}, failure); !errors.Is(err, failure) {
|
|
||||||
t.Fatalf("stopReasonFor(%v) = %v, want the failure preserved", failure, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The sentinel is matched through errors.Is, so it survives the wrapping every
|
|
||||||
// return site applies — each one adds the tool name.
|
|
||||||
func TestPermissionCancellationSurvivesWrapping(t *testing.T) {
|
|
||||||
if !errors.Is(fmt.Errorf("%w for bash", agent.ErrPermissionApprovalCanceled), agent.ErrPermissionApprovalCanceled) {
|
|
||||||
t.Fatal("a wrapped permission cancellation was not recognised")
|
|
||||||
}
|
|
||||||
if errors.Is(errors.New("something else"), agent.ErrPermissionApprovalCanceled) {
|
|
||||||
t.Fatal("an unrelated error was reported as a permission cancellation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A decision that never became an option cannot be selected.
|
|
||||||
//
|
|
||||||
// PermissionDecisionCancel is the case that exists today: optionKindFor drops
|
|
||||||
// it because ACP expresses cancellation through the outcome, yet ZERO
|
|
||||||
// enumerates it in AvailableDecisions for shell commands and apply_patch. While
|
|
||||||
// the reply was validated against the DECISIONS rather than the sent options, a
|
|
||||||
// client could return {"outcome":"selected","optionId":"cancel"} — an id it was
|
|
||||||
// never shown — and abort the whole turn.
|
|
||||||
//
|
|
||||||
// Written over every option-less decision rather than over "cancel", so a
|
|
||||||
// future action that optionKindFor declines to render is covered the day it is
|
|
||||||
// added rather than the day someone remembers this test.
|
|
||||||
func TestADecisionThatIsNotAnOptionCannotBeSelected(t *testing.T) {
|
|
||||||
for _, toolName := range []string{"bash", "apply_patch"} {
|
|
||||||
req := agent.PermissionRequest{
|
|
||||||
ToolName: toolName,
|
|
||||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
agent.PermissionDecisionCancel,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
options := buildPermissionOptions(req)
|
|
||||||
|
|
||||||
for _, action := range req.AvailableDecisions {
|
|
||||||
if actionOffered(string(action), options) {
|
|
||||||
continue // it was sent; selecting it is legitimate
|
|
||||||
}
|
|
||||||
decision := decisionFromOutcome(
|
|
||||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(action)},
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
if decision.Action != agent.PermissionDecisionDeny {
|
|
||||||
t.Fatalf("%s: %q was never sent as an option but was accepted as %q",
|
|
||||||
toolName, action, decision.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancelling is still reachable — through the outcome, which is where ACP puts
|
|
||||||
// it. Closing the selected-id route must not close the real one.
|
|
||||||
func TestCancellingThroughTheOutcomeStillWorks(t *testing.T) {
|
|
||||||
options := buildPermissionOptions(agent.PermissionRequest{ToolName: "apply_patch"})
|
|
||||||
|
|
||||||
decision := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, options)
|
|
||||||
if decision.Action != agent.PermissionDecisionCancel {
|
|
||||||
t.Fatalf("outcome=cancelled gave %q, want cancel", decision.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
+15
-180
@@ -17,7 +17,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSON-RPC 2.0 standard error codes.
|
// JSON-RPC 2.0 standard error codes.
|
||||||
@@ -81,21 +80,13 @@ type NotifyFunc func(ctx context.Context, params json.RawMessage)
|
|||||||
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
||||||
// the client for session/request_permission, fs/*, terminal/*).
|
// the client for session/request_permission, fs/*, terminal/*).
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
|
reader *bufio.Reader
|
||||||
readerCloser io.Closer
|
w io.Writer
|
||||||
w io.Writer
|
|
||||||
|
|
||||||
writeMu sync.Mutex // serializes all writes to w
|
writeMu sync.Mutex // serializes all writes to w
|
||||||
|
|
||||||
handlers map[string]HandlerFunc
|
handlers map[string]HandlerFunc
|
||||||
notifiers map[string]NotifyFunc
|
notifiers map[string]NotifyFunc
|
||||||
// Notifications for one method are an ordered stream: session/update in
|
|
||||||
// particular is stateful, so dispatching every frame in an unrelated
|
|
||||||
// goroutine can make a later chunk overtake an earlier one. Different
|
|
||||||
// methods retain independent tails, keeping session/cancel responsive while
|
|
||||||
// an update handler is busy.
|
|
||||||
notifyMu sync.Mutex
|
|
||||||
notifyTails map[string]chan struct{}
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nextID int64
|
nextID int64
|
||||||
@@ -105,35 +96,17 @@ type Conn struct {
|
|||||||
wg sync.WaitGroup // tracks in-flight inbound handlers
|
wg sync.WaitGroup // tracks in-flight inbound handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
|
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
|
||||||
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
|
|
||||||
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
|
|
||||||
// blocked until r itself produces something — e.g. a test that closes r's own
|
|
||||||
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
|
|
||||||
// lifetime.
|
|
||||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||||
return &Conn{
|
return &Conn{
|
||||||
rawReader: r,
|
reader: bufio.NewReader(r),
|
||||||
w: w,
|
w: w,
|
||||||
handlers: make(map[string]HandlerFunc),
|
handlers: make(map[string]HandlerFunc),
|
||||||
notifiers: make(map[string]NotifyFunc),
|
notifiers: make(map[string]NotifyFunc),
|
||||||
notifyTails: make(map[string]chan struct{}),
|
pending: make(map[int64]chan rpcMessage),
|
||||||
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.
|
// Handle registers a request handler for method.
|
||||||
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
||||||
|
|
||||||
@@ -155,36 +128,17 @@ func (c *Conn) Serve(ctx context.Context) error {
|
|||||||
c.wg.Wait()
|
c.wg.Wait()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
|
|
||||||
reader := bufio.NewReader(interruptible)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// One ndjson value per line. Framing per line (rather than streaming the
|
// One ndjson value per line. Framing per line (rather than streaming the
|
||||||
// decoder) keeps a single malformed line from making the whole connection
|
// decoder) keeps a single malformed line from making the whole connection
|
||||||
// unrecoverable — we report -32700 and continue.
|
// unrecoverable — we report -32700 and continue.
|
||||||
//
|
line, err := c.reader.ReadBytes('\n')
|
||||||
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
|
|
||||||
// interruptible.Read zero or more times underneath it (zero when the
|
|
||||||
// answer is already sitting in bufio's own buffer — including a
|
|
||||||
// previously-buffered, not-yet-surfaced error: bufio can return a
|
|
||||||
// complete line with a nil error while quietly caching an error it
|
|
||||||
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
|
|
||||||
// NEXT call with no further contact with interruptible.Read at all). The
|
|
||||||
// generation only advances inside interruptible.Read's own cancellation
|
|
||||||
// branch, so "unchanged across this call" is proof this call's outcome
|
|
||||||
// didn't pass through that branch — whether because it was answered from
|
|
||||||
// the buffer or because it raced ctx.Done() and won. Only that proof
|
|
||||||
// makes it safe to call the outcome genuine.
|
|
||||||
before := interruptible.generation()
|
|
||||||
line, err := reader.ReadBytes('\n')
|
|
||||||
interrupted := interruptible.generation() != before
|
|
||||||
|
|
||||||
if len(bytes.TrimSpace(line)) > 0 {
|
if len(bytes.TrimSpace(line)) > 0 {
|
||||||
c.handleLine(ctx, line)
|
c.handleLine(ctx, line)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.failAllPending(err)
|
c.failAllPending(err)
|
||||||
if errors.Is(err, io.EOF) || interrupted {
|
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -192,100 +146,6 @@ func (c *Conn) Serve(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// interruptibleReader wraps a reader so a context cancellation can unblock a
|
|
||||||
// currently in-flight Read without ever discarding — or misattributing — the
|
|
||||||
// real outcome of a call that wasn't actually blocked.
|
|
||||||
//
|
|
||||||
// Each Read runs the underlying call on its own goroutine and races it against
|
|
||||||
// ctx.Done(). If the underlying call finishes first, its result is returned
|
|
||||||
// exactly as received; ctx being cancelled a moment later has no bearing on a
|
|
||||||
// call that already had its answer. If ctx.Done() fires first, closer (when
|
|
||||||
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
|
|
||||||
// is bumped, and the call STILL waits for and returns the underlying call's own
|
|
||||||
// result — never a synthesized one — since closing a reader's outcome is
|
|
||||||
// whatever the reader itself reports for it, not ours to invent.
|
|
||||||
//
|
|
||||||
// The counter is what lets Serve tell a call that was actually raced against
|
|
||||||
// cancellation apart from one merely answered while cancellation happened to be
|
|
||||||
// pending — the distinction the read loop's own doc comment explains.
|
|
||||||
type interruptibleReader struct {
|
|
||||||
inner io.Reader
|
|
||||||
closer io.Closer
|
|
||||||
ctx context.Context
|
|
||||||
|
|
||||||
closeOnce sync.Once
|
|
||||||
gen atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
|
|
||||||
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
|
|
||||||
|
|
||||||
type interruptibleReadResult struct {
|
|
||||||
n int
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// interruptibleReadDecision arbitrates whether an underlying Read completed or
|
|
||||||
// cancellation claimed it first. Completion is recorded before the result is
|
|
||||||
// published so cancellation cannot misclassify a finished read merely because
|
|
||||||
// its goroutine has not sent the result yet.
|
|
||||||
type interruptibleReadDecision struct {
|
|
||||||
once sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
|
|
||||||
|
|
||||||
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
|
|
||||||
d.once.Do(func() { claimed = true })
|
|
||||||
return claimed
|
|
||||||
}
|
|
||||||
|
|
||||||
// readInner records completion in the Read call's deferred epilogue. It also
|
|
||||||
// converts a reader panic into a terminal error so the helper goroutine always
|
|
||||||
// publishes a result for Read to join.
|
|
||||||
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
|
|
||||||
defer func() {
|
|
||||||
if recovered := recover(); recovered != nil {
|
|
||||||
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
|
|
||||||
}
|
|
||||||
decision.complete()
|
|
||||||
}()
|
|
||||||
res.n, res.err = r.inner.Read(p)
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *interruptibleReader) Read(p []byte) (int, error) {
|
|
||||||
resultCh := make(chan interruptibleReadResult, 1)
|
|
||||||
var decision interruptibleReadDecision
|
|
||||||
go func() {
|
|
||||||
resultCh <- r.readInner(p, &decision)
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case res := <-resultCh:
|
|
||||||
return res.n, res.err
|
|
||||||
case <-r.ctx.Done():
|
|
||||||
// resultCh may not be readable yet even when inner.Read has returned: its
|
|
||||||
// goroutine can be descheduled between recording completion and publishing
|
|
||||||
// the result. Only interrupt a call that cancellation actually claims.
|
|
||||||
if decision.interrupt() {
|
|
||||||
r.gen.Add(1)
|
|
||||||
if r.closer != nil {
|
|
||||||
r.closeOnce.Do(func() { _ = r.closer.Close() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Wait for the real result rather than returning synthetically: p is
|
|
||||||
// still being written by the goroutine above until it does, so returning
|
|
||||||
// early would race that write, and the actual error (whatever closing
|
|
||||||
// the reader produced, or whatever the reader was already about to
|
|
||||||
// report) is what the caller should see.
|
|
||||||
res := <-resultCh
|
|
||||||
return res.n, res.err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
||||||
// with -32700 (id null) and keeps the connection alive.
|
// with -32700 (id null) and keeps the connection alive.
|
||||||
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||||
@@ -311,7 +171,11 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
|||||||
}(msg)
|
}(msg)
|
||||||
case msg.isNotify():
|
case msg.isNotify():
|
||||||
if fn := c.notifiers[msg.Method]; fn != nil {
|
if fn := c.notifiers[msg.Method]; fn != nil {
|
||||||
c.dispatchNotification(ctx, msg, fn)
|
c.wg.Add(1)
|
||||||
|
go func(m rpcMessage) {
|
||||||
|
defer c.wg.Done()
|
||||||
|
fn(ctx, m.Params)
|
||||||
|
}(msg)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Malformed frame; reply only if we can identify a request id.
|
// Malformed frame; reply only if we can identify a request id.
|
||||||
@@ -321,35 +185,6 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatchNotification preserves wire order within one method without making
|
|
||||||
// unrelated notification methods wait for each other. The read loop installs
|
|
||||||
// each tail before starting its goroutine, so goroutine scheduling cannot
|
|
||||||
// reorder the chain it observes.
|
|
||||||
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
|
|
||||||
c.notifyMu.Lock()
|
|
||||||
previous := c.notifyTails[msg.Method]
|
|
||||||
done := make(chan struct{})
|
|
||||||
c.notifyTails[msg.Method] = done
|
|
||||||
c.notifyMu.Unlock()
|
|
||||||
|
|
||||||
c.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer c.wg.Done()
|
|
||||||
defer func() {
|
|
||||||
close(done)
|
|
||||||
c.notifyMu.Lock()
|
|
||||||
if c.notifyTails[msg.Method] == done {
|
|
||||||
delete(c.notifyTails, msg.Method)
|
|
||||||
}
|
|
||||||
c.notifyMu.Unlock()
|
|
||||||
}()
|
|
||||||
if previous != nil {
|
|
||||||
<-previous
|
|
||||||
}
|
|
||||||
fn(ctx, msg.Params)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||||
fn := c.handlers[msg.Method]
|
fn := c.handlers[msg.Method]
|
||||||
if fn == nil {
|
if fn == nil {
|
||||||
|
|||||||
@@ -3,30 +3,11 @@ package acp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testReadCloser struct {
|
|
||||||
read func([]byte) (int, error)
|
|
||||||
close func() error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
|
|
||||||
func (r testReadCloser) Close() error { return r.close() }
|
|
||||||
|
|
||||||
type testReader func([]byte) (int, error)
|
|
||||||
|
|
||||||
func (r testReader) Read(p []byte) (int, error) { return r(p) }
|
|
||||||
|
|
||||||
type testWriter func([]byte) (int, error)
|
|
||||||
|
|
||||||
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
|
|
||||||
|
|
||||||
// connPair wires two Conns together over in-memory pipes and serves both.
|
// connPair wires two Conns together over in-memory pipes and serves both.
|
||||||
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -44,275 +25,6 @@ func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
|
|
||||||
pipeReader, writer := io.Pipe()
|
|
||||||
t.Cleanup(func() { _ = writer.Close() })
|
|
||||||
// Buffered + non-blocking send, not close: interruptibleReader retries each
|
|
||||||
// underlying Read on its own goroutine, so if bufio ever calls this more than
|
|
||||||
// once, a close() here would panic on the second call.
|
|
||||||
readStarted := make(chan struct{}, 1)
|
|
||||||
closeCalls := make(chan struct{}, 2)
|
|
||||||
reader := testReadCloser{
|
|
||||||
read: func(p []byte) (int, error) {
|
|
||||||
select {
|
|
||||||
case readStarted <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return pipeReader.Read(p)
|
|
||||||
},
|
|
||||||
close: func() error {
|
|
||||||
closeCalls <- struct{}{}
|
|
||||||
return pipeReader.Close()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
conn := NewOwnedConn(reader, io.Discard)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- conn.Serve(ctx) }()
|
|
||||||
|
|
||||||
<-readStarted
|
|
||||||
cancel()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Serve returned %v after cancellation, want nil", err)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("Serve did not return after cancelling an idle connection")
|
|
||||||
}
|
|
||||||
if got := len(closeCalls); got != 1 {
|
|
||||||
t.Fatalf("reader Close calls = %d, want 1", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
|
|
||||||
for _, closable := range []bool{false, true} {
|
|
||||||
name := "non-closable"
|
|
||||||
if closable {
|
|
||||||
name = "closable"
|
|
||||||
}
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
wantErr := errors.New("read failed")
|
|
||||||
read := testReader(func(p []byte) (int, error) {
|
|
||||||
return copy(p, "not json"), wantErr
|
|
||||||
})
|
|
||||||
closeCalls := make(chan struct{}, 1)
|
|
||||||
var reader io.Reader = read
|
|
||||||
if closable {
|
|
||||||
reader = testReadCloser{
|
|
||||||
read: read,
|
|
||||||
close: func() error {
|
|
||||||
closeCalls <- struct{}{}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Buffered + non-blocking send, not close: a later change adding a
|
|
||||||
// second write (e.g. an additional error frame) must not panic on a
|
|
||||||
// repeated close of an already-closed channel.
|
|
||||||
writeStarted := make(chan struct{}, 1)
|
|
||||||
releaseWrite := make(chan struct{})
|
|
||||||
writer := testWriter(func(p []byte) (int, error) {
|
|
||||||
select {
|
|
||||||
case writeStarted <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
<-releaseWrite
|
|
||||||
return len(p), nil
|
|
||||||
})
|
|
||||||
newConn := NewConn
|
|
||||||
if closable {
|
|
||||||
// Ownership matters here: this case exists to prove that even a
|
|
||||||
// Conn entitled to close its reader on cancellation still
|
|
||||||
// preserves a terminal error that was already decided.
|
|
||||||
newConn = NewOwnedConn
|
|
||||||
}
|
|
||||||
conn := newConn(reader, writer)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- conn.Serve(ctx) }()
|
|
||||||
|
|
||||||
<-writeStarted
|
|
||||||
cancel()
|
|
||||||
close(releaseWrite)
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("Serve did not return after terminal read error")
|
|
||||||
}
|
|
||||||
if got := len(closeCalls); got != 0 {
|
|
||||||
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
|
|
||||||
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
|
|
||||||
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
|
|
||||||
// must still be reported rather than swallowed as a clean shutdown.
|
|
||||||
//
|
|
||||||
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
|
|
||||||
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
|
|
||||||
// found within that data, ReadBytes returns the line with a nil error for THIS
|
|
||||||
// call, but bufio caches the error internally and returns it — WITHOUT calling the
|
|
||||||
// underlying reader again — on the very next ReadBytes call. This test forces that
|
|
||||||
// exact sequence: the underlying reader hands back one complete, validly-framed
|
|
||||||
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
|
|
||||||
// call), the loop dispatches that line through a synchronous path that blocks the
|
|
||||||
// read loop, cancellation is asserted to have happened before the loop attempts
|
|
||||||
// its next read, and only then is the block released. By the time the read loop
|
|
||||||
// asks for more input, cancellation is already in effect and the underlying
|
|
||||||
// reader is never touched again — so if Serve attributed the resulting error to
|
|
||||||
// cancellation merely because ctx was already done, it would incorrectly return
|
|
||||||
// nil. It must instead recognize that this read was answered entirely from
|
|
||||||
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
|
|
||||||
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
|
|
||||||
wantErr := errors.New("read failed")
|
|
||||||
var readCalls atomic.Int32
|
|
||||||
read := testReader(func(p []byte) (int, error) {
|
|
||||||
readCalls.Add(1)
|
|
||||||
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
|
|
||||||
// writeError path in handleLine (not a dispatched goroutine), giving this
|
|
||||||
// test a reliable blocking point between processing this line and the
|
|
||||||
// loop's next ReadBytes call.
|
|
||||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
|
||||||
})
|
|
||||||
writeStarted := make(chan struct{}, 1)
|
|
||||||
releaseWrite := make(chan struct{})
|
|
||||||
var releaseWriteOnce sync.Once
|
|
||||||
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
|
|
||||||
t.Cleanup(release)
|
|
||||||
writer := testWriter(func(p []byte) (int, error) {
|
|
||||||
select {
|
|
||||||
case writeStarted <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
<-releaseWrite
|
|
||||||
return len(p), nil
|
|
||||||
})
|
|
||||||
conn := NewConn(read, writer)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- conn.Serve(ctx) }()
|
|
||||||
|
|
||||||
// The synchronous write confirms the first (and, per the panic guard above,
|
|
||||||
// only) read has already returned and is being processed — cancelling now
|
|
||||||
// lands squarely in the gap between that read and the loop's next one, never
|
|
||||||
// racing an in-flight interruptibleReader.Read call.
|
|
||||||
select {
|
|
||||||
case <-writeStarted:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("Serve did not reach the synchronous write")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
release()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("Serve did not return after the buffered terminal read error")
|
|
||||||
}
|
|
||||||
if got := readCalls.Load(); got != 1 {
|
|
||||||
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
|
|
||||||
// test for jatmn's second #782 finding: interruptibleReader.Read's select
|
|
||||||
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
|
|
||||||
// so a real, already-decided outcome (e.g. a terminal transport error) could be
|
|
||||||
// misattributed to cancellation purely because ctx also happened to be done by
|
|
||||||
// the time the select ran. Each iteration lets the underlying Read return (so
|
|
||||||
// resultCh already holds the real outcome) before cancelling, maximizing the
|
|
||||||
// odds of landing exactly in that ambiguous window; the generation counter must
|
|
||||||
// never advance when the real, already-decided result is what gets returned.
|
|
||||||
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
|
|
||||||
wantErr := errors.New("read failed")
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
resultReady := make(chan struct{})
|
|
||||||
read := testReader(func(p []byte) (int, error) {
|
|
||||||
defer close(resultReady)
|
|
||||||
return copy(p, "x"), wantErr
|
|
||||||
})
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
r := newInterruptibleReader(ctx, read, nil)
|
|
||||||
|
|
||||||
readDone := make(chan interruptibleReadResult, 1)
|
|
||||||
go func() {
|
|
||||||
n, err := r.Read(make([]byte, 8))
|
|
||||||
readDone <- interruptibleReadResult{n, err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-resultReady
|
|
||||||
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
res := <-readDone
|
|
||||||
if !errors.Is(res.err, wantErr) {
|
|
||||||
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
|
|
||||||
}
|
|
||||||
if got := r.generation(); got != 0 {
|
|
||||||
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
|
|
||||||
// actual inner-read wrapper at the handoff between the underlying Read and the
|
|
||||||
// helper goroutine's result publication. Cancellation must not reclassify a
|
|
||||||
// result returned by this wrapper as interrupted.
|
|
||||||
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
|
|
||||||
wantErr := errors.New("read failed")
|
|
||||||
read := testReader(func(p []byte) (int, error) {
|
|
||||||
return copy(p, "x"), wantErr
|
|
||||||
})
|
|
||||||
r := newInterruptibleReader(context.Background(), read, nil)
|
|
||||||
var decision interruptibleReadDecision
|
|
||||||
readComplete := make(chan struct{})
|
|
||||||
resultCh := make(chan interruptibleReadResult)
|
|
||||||
go func() {
|
|
||||||
res := r.readInner(make([]byte, 8), &decision)
|
|
||||||
close(readComplete)
|
|
||||||
resultCh <- res
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-readComplete
|
|
||||||
if decision.interrupt() {
|
|
||||||
t.Fatal("cancellation claimed a read that completed before result publication")
|
|
||||||
}
|
|
||||||
res := <-resultCh
|
|
||||||
if res.n != 1 || !errors.Is(res.err, wantErr) {
|
|
||||||
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
|
|
||||||
read := testReader(func([]byte) (int, error) {
|
|
||||||
panic("boom")
|
|
||||||
})
|
|
||||||
conn := NewConn(read, io.Discard)
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- conn.Serve(context.Background()) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
const want = "acp: reader panicked: boom"
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Fatalf("Serve returned %v, want %q", err, want)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("Serve did not return after reader panic")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConnRequestResponse(t *testing.T) {
|
func TestConnRequestResponse(t *testing.T) {
|
||||||
a, b, stop := connPair(t)
|
a, b, stop := connPair(t)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -360,86 +72,6 @@ func TestConnNotification(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
|
|
||||||
a, b, stop := connPair(t)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
firstStarted := make(chan struct{})
|
|
||||||
releaseFirst := make(chan struct{})
|
|
||||||
delivered := make(chan int, 2)
|
|
||||||
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
|
|
||||||
var in struct{ Sequence int }
|
|
||||||
_ = json.Unmarshal(params, &in)
|
|
||||||
if in.Sequence == 1 {
|
|
||||||
close(firstStarted)
|
|
||||||
<-releaseFirst
|
|
||||||
}
|
|
||||||
delivered <- in.Sequence
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
|
|
||||||
t.Fatalf("first notify: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-firstStarted:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("first notification did not start")
|
|
||||||
}
|
|
||||||
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
|
|
||||||
t.Fatalf("second notify: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case sequence := <-delivered:
|
|
||||||
t.Fatalf("notification %d overtook the blocked first notification", sequence)
|
|
||||||
case <-time.After(25 * time.Millisecond):
|
|
||||||
}
|
|
||||||
close(releaseFirst)
|
|
||||||
for want := 1; want <= 2; want++ {
|
|
||||||
select {
|
|
||||||
case got := <-delivered:
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("notification order = ...,%d; want %d", got, want)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("notification %d was not delivered", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
|
|
||||||
a, b, stop := connPair(t)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
updateStarted := make(chan struct{})
|
|
||||||
releaseUpdate := make(chan struct{})
|
|
||||||
cancelDelivered := make(chan struct{})
|
|
||||||
b.HandleNotify("update", func(context.Context, json.RawMessage) {
|
|
||||||
close(updateStarted)
|
|
||||||
<-releaseUpdate
|
|
||||||
})
|
|
||||||
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
|
|
||||||
close(cancelDelivered)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := a.Notify("update", nil); err != nil {
|
|
||||||
t.Fatalf("update notify: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-updateStarted:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("update notification did not start")
|
|
||||||
}
|
|
||||||
if err := a.Notify("cancel", nil); err != nil {
|
|
||||||
t.Fatalf("cancel notify: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-cancelDelivered:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("different notification method was blocked behind update")
|
|
||||||
}
|
|
||||||
close(releaseUpdate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConnMethodNotFound(t *testing.T) {
|
func TestConnMethodNotFound(t *testing.T) {
|
||||||
a, _, stop := connPair(t)
|
a, _, stop := connPair(t)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
+23
-80
@@ -15,10 +15,17 @@ import (
|
|||||||
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
||||||
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
||||||
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||||
actions := offeredDecisions(req)
|
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))
|
options := make([]PermissionOption, 0, len(actions))
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
kind, name := optionKindFor(action)
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||||
}
|
}
|
||||||
@@ -31,61 +38,21 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
|||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
// offeredDecisions resolves what this request actually offers the client.
|
|
||||||
//
|
|
||||||
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
|
|
||||||
// The fallback below used to live inside buildPermissionOptions while
|
|
||||||
// requestPermission validated the client's answer against the raw
|
|
||||||
// req.AvailableDecisions. When ZERO did not enumerate — which is every
|
|
||||||
// permission event that is not a prompt, and includes a shell call whose sandbox
|
|
||||||
// decision came back deny — the two disagreed completely: the client was sent
|
|
||||||
// "Allow" and "Reject", and validation was performed against an EMPTY list, so
|
|
||||||
// every option the client could possibly click failed closed to deny with
|
|
||||||
// "permission option was not offered".
|
|
||||||
//
|
|
||||||
// The user clicked Allow and ZERO recorded a denial. Nothing surfaced it,
|
|
||||||
// because a denial is a legitimate answer — the tool was simply refused and the
|
|
||||||
// turn carried on as though the user had rejected it.
|
|
||||||
func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAction {
|
|
||||||
if len(req.AvailableDecisions) > 0 {
|
|
||||||
return req.AvailableDecisions
|
|
||||||
}
|
|
||||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
|
||||||
return []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
|
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
|
||||||
// human label. Returns an empty kind for actions that ACP expresses through the
|
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||||
// outcome rather than an option (cancel).
|
// outcome rather than an option (cancel).
|
||||||
//
|
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||||
// escalates reports that approving a command prefix will also run the command
|
|
||||||
// outside the sandbox. The two prefix options say so in their label, since the
|
|
||||||
// label is all an ACP client shows and the option id carries no room for it. An
|
|
||||||
// editor user is deciding on exactly the same terms as a terminal user, so the
|
|
||||||
// disclosure cannot be TUI-only.
|
|
||||||
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
|
|
||||||
switch action {
|
switch action {
|
||||||
case agent.PermissionDecisionAllow:
|
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||||
return PermAllowOnce, "Allow"
|
return PermAllowOnce, "Allow"
|
||||||
case agent.PermissionDecisionAllowStrict:
|
|
||||||
// SPLIT FROM PLAIN ALLOW because the label is the only thing an ACP
|
|
||||||
// client shows. Both were labelled "Allow", and request_permissions
|
|
||||||
// offers them together — so the panel presented two identical buttons,
|
|
||||||
// and whichever the user picked looked the same while one of them
|
|
||||||
// silently turned on strict auto-review of what was granted. The option
|
|
||||||
// id carries no room for the distinction, so it has to be in the name.
|
|
||||||
return PermAllowOnce, "Allow with strict review"
|
|
||||||
case agent.PermissionDecisionAllowForSession:
|
case agent.PermissionDecisionAllowForSession:
|
||||||
return PermAllowAlways, "Allow for this session"
|
return PermAllowAlways, "Allow for this session"
|
||||||
case agent.PermissionDecisionAllowPrefix:
|
case agent.PermissionDecisionAllowPrefix:
|
||||||
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
|
return PermAllowAlways, "Allow this command for the session"
|
||||||
case agent.PermissionDecisionAlwaysAllow:
|
case agent.PermissionDecisionAlwaysAllow:
|
||||||
return PermAllowAlways, "Always allow"
|
return PermAllowAlways, "Always allow"
|
||||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||||
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
|
return PermAllowAlways, "Always allow this command"
|
||||||
case agent.PermissionDecisionDeny:
|
case agent.PermissionDecisionDeny:
|
||||||
return PermRejectOnce, "Reject"
|
return PermRejectOnce, "Reject"
|
||||||
case agent.PermissionDecisionCancel:
|
case agent.PermissionDecisionCancel:
|
||||||
@@ -97,31 +64,19 @@ func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind,
|
|||||||
|
|
||||||
// decisionFromOutcome maps the client's permission outcome back to a ZERO
|
// decisionFromOutcome maps the client's permission outcome back to a ZERO
|
||||||
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
||||||
// action verbatim (validated against what was actually sent); anything
|
// action verbatim (validated against what was offered); anything unrecognized
|
||||||
// unrecognized fails closed to deny.
|
// fails closed to deny.
|
||||||
//
|
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
||||||
// IT TAKES THE OPTIONS THAT WERE SENT, not the decisions the request offered,
|
|
||||||
// and the two are not the same list. Some actions never become options —
|
|
||||||
// PermissionDecisionCancel is dropped by optionKindFor because ACP expresses
|
|
||||||
// cancellation through the outcome instead — yet it IS enumerated in
|
|
||||||
// AvailableDecisions for shell commands and apply_patch, the two commonest
|
|
||||||
// prompts. Validating against the decisions therefore accepted
|
|
||||||
// {"outcome":"selected","optionId":"cancel"}: an identifier no client was ever
|
|
||||||
// shown, aborting the whole turn.
|
|
||||||
//
|
|
||||||
// Passing the sent options makes "what we offered" and "what we accept" the
|
|
||||||
// same slice rather than two things kept in step, so anything optionKindFor
|
|
||||||
// declines to render is excluded structurally instead of by name.
|
|
||||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionOption) agent.PermissionDecision {
|
|
||||||
switch outcome.Outcome {
|
switch outcome.Outcome {
|
||||||
case OutcomeCancelled:
|
case OutcomeCancelled:
|
||||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
||||||
case OutcomeSelected:
|
case OutcomeSelected:
|
||||||
|
action := agent.PermissionDecisionAction(outcome.OptionID)
|
||||||
// Bind to what was actually offered for THIS call: a client must not be able
|
// Bind to what was actually offered for THIS call: a client must not be able
|
||||||
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
||||||
// presented. Anything not offered fails closed to deny.
|
// presented. Anything not offered fails closed to deny.
|
||||||
if actionOffered(outcome.OptionID, offered) {
|
if actionOffered(action, offered) {
|
||||||
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
|
return agent.PermissionDecision{Action: action}
|
||||||
}
|
}
|
||||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||||
default:
|
default:
|
||||||
@@ -129,9 +84,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionOffered(optionID string, offered []PermissionOption) bool {
|
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
||||||
for _, option := range offered {
|
for _, a := range offered {
|
||||||
if option.OptionID == optionID {
|
if a == action {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,15 +97,13 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
|
|||||||
// session/request_permission request from a ZERO permission request.
|
// session/request_permission request from a ZERO permission request.
|
||||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||||
args := marshalArgs(req.Args)
|
args := marshalArgs(req.Args)
|
||||||
upd := ToolCallUpdate{
|
return ToolCallUpdate{
|
||||||
ToolCallID: req.ToolCallID,
|
ToolCallID: req.ToolCallID,
|
||||||
Title: toolTitle(req.ToolName, string(args)),
|
Title: toolTitle(req.ToolName, string(args)),
|
||||||
Kind: toolKindFor(req.ToolName),
|
Kind: toolKindFor(req.ToolName),
|
||||||
Status: ToolStatusPending,
|
Status: ToolStatusPending,
|
||||||
RawInput: rawInputBytes(args),
|
RawInput: rawInputBytes(args),
|
||||||
}
|
}
|
||||||
attachBrowserToolDetails(&upd, req.ToolName)
|
|
||||||
return upd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalArgs(args map[string]any) []byte {
|
func marshalArgs(args map[string]any) []byte {
|
||||||
@@ -170,13 +123,3 @@ func rawInputBytes(data []byte) json.RawMessage {
|
|||||||
}
|
}
|
||||||
return json.RawMessage(data)
|
return json.RawMessage(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withSandboxEscalationNote appends the escalation disclosure to a prefix
|
|
||||||
// option's label. Kept parallel to the TUI's wording so the same consequence is
|
|
||||||
// not described two different ways depending on which client the user runs.
|
|
||||||
func withSandboxEscalationNote(name string, escalates bool) string {
|
|
||||||
if !escalates {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return name + " (runs outside the sandbox)"
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package acp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
func escalationRequest(escalates bool) agent.PermissionRequest {
|
|
||||||
return agent.PermissionRequest{
|
|
||||||
ToolName: "bash",
|
|
||||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionAllowPrefix,
|
|
||||||
agent.PermissionDecisionAlwaysAllowPrefix,
|
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
},
|
|
||||||
PrefixApprovalEscalates: escalates,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An ACP client renders only Name, so if the escalation is not in the label the
|
|
||||||
// editor user approves it blind. They face exactly the outcome a terminal user
|
|
||||||
// faces, so the disclosure has to reach both clients or it is not a fix.
|
|
||||||
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
|
|
||||||
found := 0
|
|
||||||
for _, option := range buildPermissionOptions(escalationRequest(true)) {
|
|
||||||
action := agent.PermissionDecisionAction(option.OptionID)
|
|
||||||
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
|
|
||||||
if strings.Contains(option.Name, "sandbox") {
|
|
||||||
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found++
|
|
||||||
if !strings.Contains(option.Name, "outside the sandbox") {
|
|
||||||
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found != 2 {
|
|
||||||
t.Fatalf("expected both prefix options, saw %d", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
|
|
||||||
for _, option := range buildPermissionOptions(escalationRequest(false)) {
|
|
||||||
if strings.Contains(option.Name, "sandbox") {
|
|
||||||
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The disclosure must live in the label only. The option id is the decision
|
|
||||||
// action round-tripped verbatim, and decisionFromOutcome matches it against
|
|
||||||
// what was offered, so decorating it would fail every prefix approval closed.
|
|
||||||
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
|
|
||||||
request := escalationRequest(true)
|
|
||||||
options := buildPermissionOptions(request)
|
|
||||||
for _, option := range options {
|
|
||||||
decision := decisionFromOutcome(
|
|
||||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
if string(decision.Action) != option.OptionID {
|
|
||||||
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,14 +39,11 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDecisionFromOutcome(t *testing.T) {
|
func TestDecisionFromOutcome(t *testing.T) {
|
||||||
offered := buildPermissionOptions(agent.PermissionRequest{
|
offered := []agent.PermissionDecisionAction{
|
||||||
ToolName: "bash",
|
agent.PermissionDecisionAllow,
|
||||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
agent.PermissionDecisionAlwaysAllow,
|
||||||
agent.PermissionDecisionAllow,
|
agent.PermissionDecisionDeny,
|
||||||
agent.PermissionDecisionAlwaysAllow,
|
}
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
||||||
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
||||||
}
|
}
|
||||||
@@ -82,17 +79,3 @@ func TestPermissionToolCall(t *testing.T) {
|
|||||||
t.Error("expected rawInput from args")
|
t.Error("expected rawInput from args")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
|
|
||||||
call := permissionToolCall(agent.PermissionRequest{
|
|
||||||
ToolCallID: "browser-1",
|
|
||||||
ToolName: "browser_connect",
|
|
||||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
|
||||||
})
|
|
||||||
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
|
|
||||||
t.Fatalf("browser descriptor = %#v", got)
|
|
||||||
}
|
|
||||||
if call.Title != "browser connect" {
|
|
||||||
t.Fatalf("title = %q", call.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-169
@@ -2,10 +2,7 @@ package acp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
@@ -20,14 +17,6 @@ func agentMessageChunk(delta string) ContentChunk {
|
|||||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func replayMessageChunk(role, messageID, text string) ContentChunk {
|
|
||||||
update := UpdateAgentMessageChunk
|
|
||||||
if role == "user" {
|
|
||||||
update = UpdateUserMessageChunk
|
|
||||||
}
|
|
||||||
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentThoughtChunk(delta string) ContentChunk {
|
func agentThoughtChunk(delta string) ContentChunk {
|
||||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||||
}
|
}
|
||||||
@@ -55,125 +44,12 @@ func toolKindFor(name string) string {
|
|||||||
|
|
||||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||||
func toolTitle(name, rawArgs string) string {
|
func toolTitle(name, rawArgs string) string {
|
||||||
if browser, ok := browserToolDetails(name); ok {
|
|
||||||
return browserToolTitle(browser.Command, rawArgs)
|
|
||||||
}
|
|
||||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||||
return name + " " + hint
|
return name + " " + hint
|
||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// browserToolDetails identifies ZERO's local browser helpers without treating
|
|
||||||
// similarly named MCP tools as browser automation. The descriptor intentionally
|
|
||||||
// contains no request data: ACP tool input is already protocol-visible, but a
|
|
||||||
// durable UI must not need to retain text, local CDP targets, or full URLs just
|
|
||||||
// to recognise the browser operation.
|
|
||||||
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
|
|
||||||
const prefix = "browser_"
|
|
||||||
command, ok := strings.CutPrefix(name, prefix)
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
switch command {
|
|
||||||
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
|
|
||||||
return &BrowserToolDetails{Version: 1, Command: command}, true
|
|
||||||
default:
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
|
|
||||||
|
|
||||||
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
|
|
||||||
// extension channel. Keeping this in one helper prevents start, result, and
|
|
||||||
// permission payloads from drifting onto different wire shapes.
|
|
||||||
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
|
|
||||||
browser, ok := browserToolDetails(name)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(browser)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
|
|
||||||
}
|
|
||||||
|
|
||||||
// browserToolTitle avoids putting browser_type text, an attached DevTools
|
|
||||||
// endpoint, or a URL query/fragment in a tool-card title. Those values can
|
|
||||||
// carry credentials or session data; the UI only needs the operation and, for
|
|
||||||
// navigation, a human-recognisable origin.
|
|
||||||
func browserToolTitle(command, rawArgs string) string {
|
|
||||||
switch command {
|
|
||||||
case "action":
|
|
||||||
action, ok := exactJSONStringArg(rawArgs, "command")
|
|
||||||
if !ok {
|
|
||||||
return "browser action"
|
|
||||||
}
|
|
||||||
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
|
|
||||||
return "browser action " + action
|
|
||||||
}
|
|
||||||
return "browser action"
|
|
||||||
case "open":
|
|
||||||
rawURL, ok := exactJSONStringArg(rawArgs, "url")
|
|
||||||
if !ok {
|
|
||||||
return "browser open"
|
|
||||||
}
|
|
||||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return "browser open"
|
|
||||||
}
|
|
||||||
u, err := url.Parse(normalized)
|
|
||||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
||||||
return "browser open"
|
|
||||||
}
|
|
||||||
origin := u.Scheme + "://" + u.Host
|
|
||||||
if !browserTitleTextSafe(origin) {
|
|
||||||
return "browser open"
|
|
||||||
}
|
|
||||||
return "browser open " + truncateHint(origin)
|
|
||||||
default:
|
|
||||||
return "browser " + command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
|
||||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
|
|
||||||
// format/bidi, and line/paragraph separator runes can reorder or split the
|
|
||||||
// permission label shown to a user. The execution URL remains unchanged.
|
|
||||||
func browserTitleTextSafe(text string) bool {
|
|
||||||
if !utf8.ValidString(text) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range text {
|
|
||||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
|
|
||||||
// exact JSON key is considered, and a non-string value is invalid. In
|
|
||||||
// particular, an incidental "URL" key must not change a permission title when
|
|
||||||
// browser_open will only read "url".
|
|
||||||
func exactJSONStringArg(rawArgs, key string) (string, bool) {
|
|
||||||
var args map[string]json.RawMessage
|
|
||||||
if json.Unmarshal([]byte(rawArgs), &args) != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
raw, ok := args[key]
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
var value string
|
|
||||||
if json.Unmarshal(raw, &value) != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return value, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
|
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
|
||||||
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
||||||
func primaryArgHint(rawArgs string) string {
|
func primaryArgHint(rawArgs string) string {
|
||||||
@@ -213,7 +89,7 @@ func rawInput(args string) json.RawMessage {
|
|||||||
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
||||||
// update (status in_progress — ZERO executes immediately after advertising).
|
// update (status in_progress — ZERO executes immediately after advertising).
|
||||||
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||||
upd := ToolCallUpdate{
|
return ToolCallUpdate{
|
||||||
SessionUpdate: UpdateToolCall,
|
SessionUpdate: UpdateToolCall,
|
||||||
ToolCallID: call.ID,
|
ToolCallID: call.ID,
|
||||||
Title: toolTitle(call.Name, call.Arguments),
|
Title: toolTitle(call.Name, call.Arguments),
|
||||||
@@ -221,8 +97,6 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
|||||||
Status: ToolStatusInProgress,
|
Status: ToolStatusInProgress,
|
||||||
RawInput: rawInput(call.Arguments),
|
RawInput: rawInput(call.Arguments),
|
||||||
}
|
}
|
||||||
attachBrowserToolDetails(&upd, call.Name)
|
|
||||||
return upd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||||
@@ -242,64 +116,26 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
|||||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||||
upd.Locations = locs
|
upd.Locations = locs
|
||||||
}
|
}
|
||||||
attachBrowserToolDetails(&upd, result.Name)
|
|
||||||
return upd
|
return upd
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||||
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
|
|
||||||
text := strings.TrimRight(result.Output, "\n")
|
text := strings.TrimRight(result.Output, "\n")
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = result.Display.Summary
|
text = result.Display.Summary
|
||||||
}
|
}
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return appendToolResultDiffs(content, result.FileDiffs)
|
return nil
|
||||||
}
|
}
|
||||||
content = append(content, ToolContent(TextBlock(text)))
|
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||||
return appendToolResultDiffs(content, result.FileDiffs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
|
|
||||||
for _, diff := range diffs {
|
|
||||||
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var oldText *string
|
|
||||||
if diff.OldExists {
|
|
||||||
old := diff.OldText
|
|
||||||
oldText = &old
|
|
||||||
}
|
|
||||||
var newText *string
|
|
||||||
if diff.NewExists {
|
|
||||||
updated := diff.NewText
|
|
||||||
newText = &updated
|
|
||||||
}
|
|
||||||
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||||
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
|
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||||
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
|
|
||||||
for _, diff := range result.FileDiffs {
|
|
||||||
path := diff.Path
|
|
||||||
if path == "" || seen[path] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[path] = true
|
|
||||||
locs = append(locs, ToolCallLocation{Path: path})
|
|
||||||
}
|
|
||||||
// FileDiff.Path is canonical absolute path data while ChangedFiles is
|
|
||||||
// normally workspace-relative. Without the trusted workspace root these
|
|
||||||
// coordinate systems cannot be correlated safely: a suffix match would let
|
|
||||||
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
|
|
||||||
// shared seen set deduplicates only identities already exactly comparable.
|
|
||||||
for _, f := range result.ChangedFiles {
|
for _, f := range result.ChangedFiles {
|
||||||
if f == "" || seen[f] {
|
if strings.TrimSpace(f) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[f] = true
|
|
||||||
locs = append(locs, ToolCallLocation{Path: f})
|
locs = append(locs, ToolCallLocation{Path: f})
|
||||||
}
|
}
|
||||||
return locs
|
return locs
|
||||||
|
|||||||
@@ -1,30 +1,14 @@
|
|||||||
package acp
|
package acp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
|
|
||||||
t.Helper()
|
|
||||||
raw, ok := update.Meta[zeroBrowserMetaKey]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
|
|
||||||
}
|
|
||||||
var details BrowserToolDetails
|
|
||||||
if err := json.Unmarshal(raw, &details); err != nil {
|
|
||||||
t.Fatalf("decode browser metadata: %v", err)
|
|
||||||
}
|
|
||||||
return details
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||||
m := agentMessageChunk("hello")
|
m := agentMessageChunk("hello")
|
||||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||||
@@ -72,217 +56,6 @@ func TestToolTitleAndHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
|
|
||||||
start := toolCallStart(agent.ToolCall{
|
|
||||||
ID: "browser-1",
|
|
||||||
Name: "browser_open",
|
|
||||||
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
|
|
||||||
})
|
|
||||||
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
|
|
||||||
t.Fatalf("browser descriptor = %#v, want open", got)
|
|
||||||
}
|
|
||||||
if start.Title != "browser open https://example.com" {
|
|
||||||
t.Fatalf("browser title = %q", start.Title)
|
|
||||||
}
|
|
||||||
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
|
|
||||||
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
|
|
||||||
}
|
|
||||||
encoded, err := json.Marshal(start)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var wire struct {
|
|
||||||
Meta map[string]json.RawMessage `json:"_meta"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
|
|
||||||
t.Fatalf("browser wire metadata = %#v", wire.Meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
typed := toolCallStart(agent.ToolCall{
|
|
||||||
ID: "browser-2",
|
|
||||||
Name: "browser_type",
|
|
||||||
Arguments: `{"ref":"email","text":"[email protected]"}`,
|
|
||||||
})
|
|
||||||
if got := browserDescriptor(t, typed); got.Command != "type" {
|
|
||||||
t.Fatalf("browser type descriptor = %#v", got)
|
|
||||||
}
|
|
||||||
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
|
|
||||||
t.Fatalf("browser type title = %q", typed.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
action := toolCallStart(agent.ToolCall{
|
|
||||||
ID: "browser-3",
|
|
||||||
Name: "browser_action",
|
|
||||||
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
|
|
||||||
})
|
|
||||||
if action.Title != "browser action keyboard_insert_text" {
|
|
||||||
t.Fatalf("browser action title = %q", action.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := toolCallResult(agent.ToolResult{
|
|
||||||
ToolCallID: "browser-2",
|
|
||||||
Name: "browser_type",
|
|
||||||
Status: tools.StatusOK,
|
|
||||||
})
|
|
||||||
if got := browserDescriptor(t, result); got.Command != "type" {
|
|
||||||
t.Fatalf("browser result descriptor = %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
|
|
||||||
updates := []ToolCallUpdate{
|
|
||||||
toolCallStart(agent.ToolCall{
|
|
||||||
ID: "start",
|
|
||||||
Name: "browser_open",
|
|
||||||
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
|
|
||||||
}),
|
|
||||||
toolCallResult(agent.ToolResult{
|
|
||||||
ToolCallID: "result",
|
|
||||||
Name: "browser_type",
|
|
||||||
Status: tools.StatusOK,
|
|
||||||
}),
|
|
||||||
permissionToolCall(agent.PermissionRequest{
|
|
||||||
ToolCallID: "permission",
|
|
||||||
ToolName: "browser_connect",
|
|
||||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
type protocolToolCallUpdate struct {
|
|
||||||
SessionUpdate string `json:"sessionUpdate,omitempty"`
|
|
||||||
ToolCallID string `json:"toolCallId"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Kind string `json:"kind,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
|
||||||
Content []ToolCallContent `json:"content,omitempty"`
|
|
||||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
|
||||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, update := range updates {
|
|
||||||
encoded, err := json.Marshal(update)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var root map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(encoded, &root); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, ok := root["browser"]; ok {
|
|
||||||
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
var protocol protocolToolCallUpdate
|
|
||||||
if err := json.Unmarshal(encoded, &protocol); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
forwarded, err := json.Marshal(protocol)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var roundTripped ToolCallUpdate
|
|
||||||
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
details := browserDescriptor(t, roundTripped)
|
|
||||||
if details.Version != 1 || details.Command == "" {
|
|
||||||
t.Fatalf("round-tripped browser descriptor = %#v", details)
|
|
||||||
}
|
|
||||||
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
|
|
||||||
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
|
|
||||||
if strings.Contains(descriptorJSON, secret) {
|
|
||||||
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
|
|
||||||
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
|
|
||||||
t.Fatalf("bare-host title = %q", got)
|
|
||||||
}
|
|
||||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
|
|
||||||
t.Fatalf("case-variant URL title = %q", got)
|
|
||||||
}
|
|
||||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
|
|
||||||
t.Fatalf("exact URL key title = %q", got)
|
|
||||||
}
|
|
||||||
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
|
|
||||||
t.Fatalf("unknown browser action title = %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
|
|
||||||
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
|
|
||||||
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
|
|
||||||
t.Fatalf("bounded browser origin title = %q", title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
|
|
||||||
for _, rawURL := range []string{
|
|
||||||
"https://safe.example%E2%80%AEevil.test/path",
|
|
||||||
"https://safe.example%E2%81%A6evil.test/path",
|
|
||||||
"https://safe.example%C2%85evil.test/path",
|
|
||||||
"https://safe.example%E2%80%A8evil.test/path",
|
|
||||||
"https://safe.example%E2%80%A9evil.test/path",
|
|
||||||
} {
|
|
||||||
t.Run(rawURL, func(t *testing.T) {
|
|
||||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("execution URL rejected: %v", err)
|
|
||||||
}
|
|
||||||
if normalized != rawURL {
|
|
||||||
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
args, err := json.Marshal(map[string]any{"url": rawURL})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
updates := []ToolCallUpdate{
|
|
||||||
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
|
|
||||||
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
|
|
||||||
}
|
|
||||||
for _, update := range updates {
|
|
||||||
encoded, err := json.Marshal(update)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var decoded ToolCallUpdate
|
|
||||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if decoded.Title != "browser open" {
|
|
||||||
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
|
|
||||||
}
|
|
||||||
for _, r := range decoded.Title {
|
|
||||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
|
||||||
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
|
|
||||||
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
|
|
||||||
if len(start.Meta) != 0 {
|
|
||||||
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
|
|
||||||
}
|
|
||||||
encoded, err := json.Marshal(start)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(encoded), `"browser"`) {
|
|
||||||
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallStart(t *testing.T) {
|
func TestToolCallStart(t *testing.T) {
|
||||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||||
if upd.SessionUpdate != UpdateToolCall {
|
if upd.SessionUpdate != UpdateToolCall {
|
||||||
@@ -301,26 +74,21 @@ func TestToolCallStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestToolCallResult(t *testing.T) {
|
func TestToolCallResult(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "a.go")
|
|
||||||
ok := toolCallResult(agent.ToolResult{
|
ok := toolCallResult(agent.ToolResult{
|
||||||
ToolCallID: "tc1",
|
ToolCallID: "tc1",
|
||||||
Name: "edit_file",
|
Name: "edit_file",
|
||||||
Status: tools.StatusOK,
|
Status: tools.StatusOK,
|
||||||
Output: "applied\n",
|
Output: "applied\n",
|
||||||
ChangedFiles: []string{"a.go", ""},
|
ChangedFiles: []string{"a.go", ""},
|
||||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
|
|
||||||
})
|
})
|
||||||
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
||||||
t.Fatalf("unexpected ok result: %+v", ok)
|
t.Fatalf("unexpected ok result: %+v", ok)
|
||||||
}
|
}
|
||||||
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||||
t.Fatalf("unexpected content: %+v", ok.Content)
|
t.Fatalf("unexpected content: %+v", ok.Content)
|
||||||
}
|
}
|
||||||
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
|
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
|
||||||
t.Fatalf("unexpected diff content: %+v", diff)
|
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
|
||||||
}
|
|
||||||
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
|
|
||||||
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||||
@@ -329,190 +97,6 @@ func TestToolCallResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
|
||||||
content := appendToolResultDiffs(nil, []tools.FileDiff{
|
|
||||||
{Path: path, OldExists: false, NewExists: true, NewText: ""},
|
|
||||||
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
|
|
||||||
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
|
|
||||||
})
|
|
||||||
if len(content) != 3 {
|
|
||||||
t.Fatalf("diff content = %#v", content)
|
|
||||||
}
|
|
||||||
for index, diff := range content {
|
|
||||||
encoded, err := json.Marshal(diff)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var wire map[string]any
|
|
||||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if wire["path"] != path {
|
|
||||||
t.Fatalf("wire diff %d = %s", index, encoded)
|
|
||||||
}
|
|
||||||
switch index {
|
|
||||||
case 0:
|
|
||||||
oldText, present := wire["oldText"]
|
|
||||||
if !present || oldText != nil || wire["newText"] != "" {
|
|
||||||
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
|
|
||||||
}
|
|
||||||
case 1:
|
|
||||||
if wire["oldText"] != "before" || wire["newText"] != "" {
|
|
||||||
t.Fatalf("empty replacement diff = %s", encoded)
|
|
||||||
}
|
|
||||||
case 2:
|
|
||||||
newText, present := wire["newText"]
|
|
||||||
if wire["oldText"] != "before" || !present || newText != nil {
|
|
||||||
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
rootPath := filepath.Join(root, "a.go")
|
|
||||||
nestedPath := filepath.Join(root, "sub", "a.go")
|
|
||||||
diff := func(path string) tools.FileDiff {
|
|
||||||
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
|
|
||||||
}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
diffs []tools.FileDiff
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
|
||||||
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
|
|
||||||
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
locations := toolResultLocations(agent.ToolResult{
|
|
||||||
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
|
|
||||||
FileDiffs: tc.diffs,
|
|
||||||
})
|
|
||||||
if len(locations) != len(tc.want) {
|
|
||||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
|
||||||
}
|
|
||||||
for index := range tc.want {
|
|
||||||
if locations[index].Path != tc.want[index] {
|
|
||||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
|
|
||||||
relativePath := " report.txt "
|
|
||||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
|
||||||
update := toolCallResult(agent.ToolResult{
|
|
||||||
ChangedFiles: []string{relativePath},
|
|
||||||
FileDiffs: []tools.FileDiff{{
|
|
||||||
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
|
|
||||||
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
|
|
||||||
}
|
|
||||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
|
||||||
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "a.go")
|
|
||||||
locations := toolResultLocations(agent.ToolResult{
|
|
||||||
ChangedFiles: []string{path, path},
|
|
||||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
|
|
||||||
})
|
|
||||||
if len(locations) != 1 || locations[0].Path != path {
|
|
||||||
t.Fatalf("exact duplicate locations = %#v", locations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
|
|
||||||
relativePath := "deleted.go"
|
|
||||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
|
||||||
update := toolCallResult(agent.ToolResult{
|
|
||||||
ChangedFiles: []string{relativePath},
|
|
||||||
FileDiffs: []tools.FileDiff{{
|
|
||||||
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
|
|
||||||
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
|
|
||||||
}
|
|
||||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
|
||||||
t.Fatalf("deleted file locations = %#v", update.Locations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
|
|
||||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
|
||||||
path := filepath.Join(t.TempDir(), "secret.txt")
|
|
||||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
|
|
||||||
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
|
|
||||||
}}})
|
|
||||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
|
|
||||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
|
|
||||||
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
|
|
||||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
|
||||||
scrubbed := tools.ScrubResultSecrets(tools.Result{
|
|
||||||
ChangedFiles: []string{"credentials.txt"},
|
|
||||||
FileDiffs: []tools.FileDiff{{
|
|
||||||
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
|
|
||||||
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
update := toolCallResult(agent.ToolResult{
|
|
||||||
ToolCallID: "call", Status: tools.StatusOK,
|
|
||||||
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
|
|
||||||
})
|
|
||||||
if len(update.Content) != 0 {
|
|
||||||
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
|
|
||||||
}
|
|
||||||
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
|
|
||||||
t.Fatalf("ACP path fallback = %#v", update.Locations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
|
|
||||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
|
||||||
for name, separator := range map[string]string{
|
|
||||||
"combining grapheme joiner": "\u034f",
|
|
||||||
"variation selector": "\ufe0f",
|
|
||||||
} {
|
|
||||||
for _, side := range []string{"old", "new"} {
|
|
||||||
t.Run(name+" "+side, func(t *testing.T) {
|
|
||||||
obfuscated := secret[:20] + separator + secret[20:]
|
|
||||||
diff := tools.FileDiff{
|
|
||||||
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
|
|
||||||
OldText: "safe old", NewText: "safe new",
|
|
||||||
}
|
|
||||||
if side == "old" {
|
|
||||||
diff.OldText = obfuscated
|
|
||||||
} else {
|
|
||||||
diff.NewText = obfuscated
|
|
||||||
}
|
|
||||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
|
|
||||||
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
|
|
||||||
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
|
|
||||||
}
|
|
||||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
|
|
||||||
if len(update.Content) != 0 {
|
|
||||||
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||||
upd := planUpdate([]tools.PlanItem{
|
upd := planUpdate([]tools.PlanItem{
|
||||||
{Content: "step a", Status: "completed"},
|
{Content: "step a", Status: "completed"},
|
||||||
|
|||||||
+27
-112
@@ -12,8 +12,6 @@ const (
|
|||||||
MethodAuthenticate = "authenticate"
|
MethodAuthenticate = "authenticate"
|
||||||
MethodSessionNew = "session/new"
|
MethodSessionNew = "session/new"
|
||||||
MethodSessionLoad = "session/load"
|
MethodSessionLoad = "session/load"
|
||||||
MethodSessionList = "session/list"
|
|
||||||
MethodSessionResume = "session/resume"
|
|
||||||
MethodSessionPrompt = "session/prompt"
|
MethodSessionPrompt = "session/prompt"
|
||||||
MethodSessionCancel = "session/cancel" // notification
|
MethodSessionCancel = "session/cancel" // notification
|
||||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||||
@@ -64,16 +62,8 @@ type PromptCapabilities struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentCapabilities struct {
|
type AgentCapabilities struct {
|
||||||
LoadSession bool `json:"loadSession"`
|
LoadSession bool `json:"loadSession"`
|
||||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||||
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty capability objects are presence flags in ACP v1. Pointers preserve
|
|
||||||
// the wire distinction between an advertised `{}` and an omitted capability.
|
|
||||||
type SessionCapabilities struct {
|
|
||||||
List *struct{} `json:"list,omitempty"`
|
|
||||||
Resume *struct{} `json:"resume,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthMethod struct {
|
type AuthMethod struct {
|
||||||
@@ -113,6 +103,10 @@ type ContentBlock struct {
|
|||||||
|
|
||||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||||
|
|
||||||
|
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||||
|
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- sessions ----
|
// ---- sessions ----
|
||||||
|
|
||||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||||
@@ -127,14 +121,9 @@ type McpServer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NewSessionParams struct {
|
type NewSessionParams struct {
|
||||||
Cwd string `json:"cwd"`
|
Cwd string `json:"cwd"`
|
||||||
McpServers []McpServer `json:"mcpServers"`
|
McpServers []McpServer `json:"mcpServers"`
|
||||||
// AdditionalDirectories is declared by the protocol and consumed nowhere in
|
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||||
// this repo yet. When it is wired up it must go through requestedWorkspace
|
|
||||||
// like Cwd does: these are client-supplied paths with no absoluteness rule of
|
|
||||||
// their own, which is the same shape as the resume-cwd hole that made
|
|
||||||
// {"sessionId":"known"} activate a session against this process's directory.
|
|
||||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NewSessionResult struct {
|
type NewSessionResult struct {
|
||||||
@@ -155,39 +144,6 @@ type LoadSessionResult struct {
|
|||||||
Modes *SessionModeState `json:"modes,omitempty"`
|
Modes *SessionModeState `json:"modes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSessionsParams and the following types implement ACP v1 session/list.
|
|
||||||
// Transcript contents stay behind session/load; this method returns metadata
|
|
||||||
// only and leaves the optional pagination cursor opaque.
|
|
||||||
type ListSessionsParams struct {
|
|
||||||
Cwd string `json:"cwd,omitempty"`
|
|
||||||
Cursor string `json:"cursor,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionInfoMeta struct {
|
|
||||||
ModelID string `json:"modelId,omitempty"`
|
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionInfo struct {
|
|
||||||
SessionID string `json:"sessionId"`
|
|
||||||
Cwd string `json:"cwd"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
||||||
Meta *SessionInfoMeta `json:"_meta,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListSessionsResult struct {
|
|
||||||
Sessions []SessionInfo `json:"sessions"`
|
|
||||||
NextCursor string `json:"nextCursor,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// session/resume takes session/load's wire shape, so its cwd is a plain string
|
|
||||||
// too and an omitted one decodes exactly like an empty one. Sharing the type is
|
|
||||||
// safe only because activatePersistedSession requires an absolute cwd from the
|
|
||||||
// request itself — nothing downstream may supply a default for either method.
|
|
||||||
type ResumeSessionParams = LoadSessionParams
|
|
||||||
type ResumeSessionResult = LoadSessionResult
|
|
||||||
|
|
||||||
// ---- prompt turn ----
|
// ---- prompt turn ----
|
||||||
|
|
||||||
type PromptParams struct {
|
type PromptParams struct {
|
||||||
@@ -222,7 +178,6 @@ type SessionNotification struct {
|
|||||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||||
type ContentChunk struct {
|
type ContentChunk struct {
|
||||||
SessionUpdate string `json:"sessionUpdate"`
|
SessionUpdate string `json:"sessionUpdate"`
|
||||||
MessageID string `json:"messageId,omitempty"`
|
|
||||||
Content ContentBlock `json:"content"`
|
Content ContentBlock `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,21 +214,6 @@ type ToolCallUpdate struct {
|
|||||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||||
Content []ToolCallContent `json:"content,omitempty"`
|
Content []ToolCallContent `json:"content,omitempty"`
|
||||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||||
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
|
|
||||||
// namespaced key so protocol-shaped clients can preserve them while decoding
|
|
||||||
// and re-encoding a tool call.
|
|
||||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BrowserToolDetails identifies the browser helper operation behind a tool
|
|
||||||
// call. Version is the schema version for this optional ZERO extension;
|
|
||||||
// Command is one of install, launch, connect, open, snapshot, click, type,
|
|
||||||
// press, or action. Future fields must remain display-safe and must not
|
|
||||||
// include browser profile data, cookies, typed text, URL paths/queries, or
|
|
||||||
// DevTools endpoints.
|
|
||||||
type BrowserToolDetails struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Command string `json:"command"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||||
@@ -284,36 +224,19 @@ type ToolCallContent struct {
|
|||||||
// type == "content"
|
// type == "content"
|
||||||
Content *ContentBlock `json:"content,omitempty"`
|
Content *ContentBlock `json:"content,omitempty"`
|
||||||
// type == "diff"
|
// type == "diff"
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
OldText *string `json:"oldText,omitempty"`
|
OldText string `json:"oldText,omitempty"`
|
||||||
NewText *string `json:"newText,omitempty"`
|
NewText string `json:"newText,omitempty"`
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON preserves ACP's discriminated content union. A diff always has
|
|
||||||
// path, while nullable oldText/newText distinguish creation, deletion, and an
|
|
||||||
// existing file replaced with empty content. Other content variants omit all
|
|
||||||
// diff fields rather than serializing irrelevant nulls.
|
|
||||||
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
|
|
||||||
if content.Type == "diff" {
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
OldText *string `json:"oldText"`
|
|
||||||
NewText *string `json:"newText"`
|
|
||||||
}{
|
|
||||||
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Content *ContentBlock `json:"content,omitempty"`
|
|
||||||
}{Type: content.Type, Content: content.Content})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToolContent(block ContentBlock) ToolCallContent {
|
func ToolContent(block ContentBlock) ToolCallContent {
|
||||||
return ToolCallContent{Type: "content", Content: &block}
|
return ToolCallContent{Type: "content", Content: &block}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
||||||
|
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
||||||
|
}
|
||||||
|
|
||||||
type ToolCallLocation struct {
|
type ToolCallLocation struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Line *int `json:"line,omitempty"`
|
Line *int `json:"line,omitempty"`
|
||||||
@@ -414,22 +337,19 @@ type SetSessionModeParams struct {
|
|||||||
|
|
||||||
type SetSessionModeResult struct{}
|
type SetSessionModeResult struct{}
|
||||||
|
|
||||||
// ---- session config options ----
|
// ---- session config options (model selection) ----
|
||||||
|
|
||||||
type SessionConfigOptionValue struct {
|
type SessionConfigOptionValue struct {
|
||||||
Value string `json:"value"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionConfigOption struct {
|
type SessionConfigOption struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Category string `json:"category,omitempty"`
|
Value string `json:"value"`
|
||||||
Type string `json:"type"`
|
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
||||||
CurrentValue string `json:"currentValue"`
|
|
||||||
Options []SessionConfigOptionValue `json:"options"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetSessionConfigOptionParams struct {
|
type SetSessionConfigOptionParams struct {
|
||||||
@@ -453,11 +373,6 @@ type ZeroSetModelResult struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
||||||
configIDModel = "model"
|
// through the standard session/set_config_option method.
|
||||||
configIDMode = "mode"
|
const configIDModel = "model"
|
||||||
|
|
||||||
configOptionTypeSelect = "select"
|
|
||||||
configCategoryModel = "model"
|
|
||||||
configCategoryMode = "mode"
|
|
||||||
)
|
|
||||||
@@ -5,29 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
type normalizationRecordingShellTool struct {
|
|
||||||
calls []map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
|
|
||||||
func (tool *normalizationRecordingShellTool) Description() string {
|
|
||||||
return "record normalized shell arguments"
|
|
||||||
}
|
|
||||||
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
|
|
||||||
return tools.Schema{Type: "object", AdditionalProperties: true}
|
|
||||||
}
|
|
||||||
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
|
|
||||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
|
|
||||||
}
|
|
||||||
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
|
|
||||||
tool.calls = append(tool.calls, cloneArgs(args))
|
|
||||||
return tools.Result{Status: tools.StatusOK}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||||
// omits (or malforms) additional_permissions can never succeed: the same
|
// omits (or malforms) additional_permissions can never succeed: the same
|
||||||
// validation runs again at grant time regardless of the user's decision. It
|
// validation runs again at grant time regardless of the user's decision. It
|
||||||
@@ -36,7 +16,7 @@ func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[str
|
|||||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
promptCalls := 0
|
promptCalls := 0
|
||||||
result, err := executeToolCall(
|
result, err := executeToolCall(
|
||||||
@@ -71,7 +51,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
|
|||||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
promptCalls := 0
|
promptCalls := 0
|
||||||
result, err := executeToolCall(
|
result, err := executeToolCall(
|
||||||
@@ -108,7 +88,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
|
|||||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
result, err := executeToolCall(
|
result, err := executeToolCall(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -127,7 +107,9 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
|||||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||||
}
|
}
|
||||||
assertNamesOmitRemedy(t, "missing additional_permissions", 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
|
// A well-formed additional_permissions payload must still reach the normal
|
||||||
@@ -135,7 +117,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
|||||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
promptCalls := 0
|
promptCalls := 0
|
||||||
_, err := executeToolCall(
|
_, err := executeToolCall(
|
||||||
@@ -162,237 +144,3 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
|
|||||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runShellCall drives one shell tool call through the real registry (and so
|
|
||||||
// through Registry.RunWithOptions) exactly as the agent loop would.
|
|
||||||
func runShellCall(t *testing.T, root string, arguments string) ToolResult {
|
|
||||||
t.Helper()
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedExecCommandTool(root, nil, nil))
|
|
||||||
result, err := executeToolCall(
|
|
||||||
context.Background(),
|
|
||||||
registry,
|
|
||||||
ToolCall{ID: "c1", Name: "exec_command", Arguments: arguments},
|
|
||||||
PermissionModeUnsafe,
|
|
||||||
Options{Cwd: root},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAdditionalPermissionsRejectionsConverge is the regression guard for
|
|
||||||
// #845/#847. A model that attaches a permission object to a command needing
|
|
||||||
// none used to be told only "set sandbox_permissions", which pushed it into the
|
|
||||||
// OTHER invalid shape (flag set, payload empty) and its "include a permission"
|
|
||||||
// error pushed it straight back — the reported loop that burned turns on
|
|
||||||
// `git branch --show-current`.
|
|
||||||
//
|
|
||||||
// The contract this pins is convergence, not tolerance: both shapes are still
|
|
||||||
// REJECTED (nothing here grants access), but each rejection must name the one
|
|
||||||
// follow-up call that works, and that follow-up must actually succeed.
|
|
||||||
func TestAdditionalPermissionsRejectionsConverge(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
const readOnly = `"cmd":"git --version"`
|
|
||||||
|
|
||||||
// Step 1 of the reported loop: a permission object with no flag.
|
|
||||||
first := runShellCall(t, root, `{`+readOnly+`,"additional_permissions":{"network":{"enabled":true}}}`)
|
|
||||||
if first.Status != tools.StatusError {
|
|
||||||
t.Fatalf("unflagged additional_permissions status = %q, want error (rejecting is correct)", first.Status)
|
|
||||||
}
|
|
||||||
assertNamesOmitRemedy(t, "unflagged additional_permissions", first.Output)
|
|
||||||
|
|
||||||
// Step 2 of the reported loop: the model keeps the flag but empties the
|
|
||||||
// payload. This must not be the dead end it was.
|
|
||||||
second := runShellCall(t, root, `{`+readOnly+`,"sandbox_permissions":"with_additional_permissions"}`)
|
|
||||||
if second.Status != tools.StatusError {
|
|
||||||
t.Fatalf("flag without payload status = %q, want error", second.Status)
|
|
||||||
}
|
|
||||||
assertNamesOmitRemedy(t, "flag without payload", second.Output)
|
|
||||||
|
|
||||||
// The call both errors point at must actually work.
|
|
||||||
converged := runShellCall(t, root, `{`+readOnly+`}`)
|
|
||||||
if converged.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("the call both errors instruct the model to make failed: status=%q output=%q", converged.Status, converged.Output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertNamesOmitRemedy pins the actionable half of the error text: the message
|
|
||||||
// must spell out that BOTH fields can simply be dropped. "Set the flag" or
|
|
||||||
// "include a permission" alone is what made the two errors chase each other.
|
|
||||||
func assertNamesOmitRemedy(t *testing.T, label string, output string) {
|
|
||||||
t.Helper()
|
|
||||||
if !strings.Contains(output, "omit both sandbox_permissions and additional_permissions") {
|
|
||||||
t.Fatalf("%s error = %q, want it to say to omit both fields entirely", label, output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "retry") {
|
|
||||||
t.Fatalf("%s error = %q, want it to tell the model to retry", label, output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The empty-payload rejection carries the same remedy. It is pinned on the
|
|
||||||
// validator directly because normalizeNoopAdditionalPermissions absorbs the
|
|
||||||
// empty payload before executeToolCall reaches this branch;
|
|
||||||
// inlineAdditionalPermissionsProfile is still the shared source of truth that
|
|
||||||
// buildPermissionEvent and the grant path re-run, so its text must converge too.
|
|
||||||
func TestEmptyAdditionalPermissionsErrorNamesOmitRemedy(t *testing.T) {
|
|
||||||
args := map[string]any{
|
|
||||||
"cmd": "git --version",
|
|
||||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
|
||||||
"additional_permissions": map[string]any{
|
|
||||||
"network": map[string]any{"enabled": false},
|
|
||||||
"file_system": map[string]any{"read": []any{}, "write": []any{}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_, requested, err := inlineAdditionalPermissionsProfile(args, t.TempDir())
|
|
||||||
if !requested {
|
|
||||||
t.Fatal("requested = false, want true for an explicit with_additional_permissions call")
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an empty additional_permissions payload was accepted; it must still be rejected")
|
|
||||||
}
|
|
||||||
assertNamesOmitRemedy(t, "empty payload", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// A provider that materializes every optional property may send
|
|
||||||
// "additional_permissions": null. That grants nothing and carries no flag, so it
|
|
||||||
// is the field omitted — rejecting it stalled an ordinary command behind an
|
|
||||||
// error the model could not act on.
|
|
||||||
func TestExecuteToolCallTreatsNullAdditionalPermissionsAsOmitted(t *testing.T) {
|
|
||||||
result := runShellCall(t, t.TempDir(), `{"cmd":"git --version","additional_permissions":null}`)
|
|
||||||
if result.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("null additional_permissions was rejected: status=%q output=%q", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A null payload alongside the flag must still be rejected, and must land on
|
|
||||||
// the same error as an absent payload rather than being quietly downgraded.
|
|
||||||
func TestExecuteToolCallRejectsFlaggedNullAdditionalPermissions(t *testing.T) {
|
|
||||||
result := runShellCall(t, t.TempDir(),
|
|
||||||
`{"cmd":"git --version","sandbox_permissions":"with_additional_permissions","additional_permissions":null}`)
|
|
||||||
if result.Status != tools.StatusError {
|
|
||||||
t.Fatalf("flagged null status = %q, want error", result.Status)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.Output, "no additional_permissions object was provided") {
|
|
||||||
t.Fatalf("output = %q, want the missing-payload error", result.Output)
|
|
||||||
}
|
|
||||||
assertNamesOmitRemedy(t, "flagged null", result.Output)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
|
||||||
for _, mode := range []string{
|
|
||||||
string(tools.SandboxPermissionsUseDefault),
|
|
||||||
string(tools.SandboxPermissionsWithAdditionalPermissions),
|
|
||||||
} {
|
|
||||||
args := map[string]any{
|
|
||||||
"command": "./zero --version",
|
|
||||||
"sandbox_permissions": mode,
|
|
||||||
"additional_permissions": map[string]any{
|
|
||||||
"file_system": map[string]any{
|
|
||||||
"deny_read": []any{},
|
|
||||||
"entries": []any{},
|
|
||||||
"read": []any{},
|
|
||||||
"write": []any{},
|
|
||||||
},
|
|
||||||
"network": map[string]any{"enabled": false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
|
||||||
if _, exists := args["additional_permissions"]; exists {
|
|
||||||
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
|
|
||||||
}
|
|
||||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
|
||||||
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
|
||||||
tool := &normalizationRecordingShellTool{}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tool)
|
|
||||||
|
|
||||||
result, err := executeToolCall(
|
|
||||||
context.Background(),
|
|
||||||
registry,
|
|
||||||
ToolCall{
|
|
||||||
ID: "c1",
|
|
||||||
Name: "bash",
|
|
||||||
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
|
|
||||||
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
|
|
||||||
},
|
|
||||||
PermissionModeAuto,
|
|
||||||
Options{Cwd: t.TempDir()},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
|
|
||||||
}
|
|
||||||
if len(tool.calls) != 1 {
|
|
||||||
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
|
|
||||||
}
|
|
||||||
args := tool.calls[0]
|
|
||||||
if _, exists := args["additional_permissions"]; exists {
|
|
||||||
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
|
|
||||||
}
|
|
||||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
|
||||||
t.Fatalf("sandbox mode = %q, want use_default", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
|
|
||||||
args := map[string]any{
|
|
||||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
|
||||||
"additional_permissions": map[string]any{
|
|
||||||
"network": map[string]any{"enabled": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
|
||||||
if _, exists := args["additional_permissions"]; !exists {
|
|
||||||
t.Fatal("non-empty additional_permissions was removed")
|
|
||||||
}
|
|
||||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
|
||||||
t.Fatalf("sandbox mode changed to %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
||||||
WorkspaceRoot: root,
|
|
||||||
Policy: sandbox.DefaultPolicy(),
|
|
||||||
})
|
|
||||||
args := map[string]any{
|
|
||||||
"command": "./zero --version",
|
|
||||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
|
||||||
"additional_permissions": map[string]any{
|
|
||||||
"file_system": map[string]any{
|
|
||||||
"entries": []any{
|
|
||||||
map[string]any{
|
|
||||||
"access": "read",
|
|
||||||
"path": map[string]any{
|
|
||||||
"type": "path",
|
|
||||||
"path": ".",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"deny_read": []any{},
|
|
||||||
"read": []any{},
|
|
||||||
"write": []any{},
|
|
||||||
},
|
|
||||||
"network": map[string]any{"enabled": false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeNoopAdditionalPermissions(args, root, engine)
|
|
||||||
|
|
||||||
if _, exists := args["additional_permissions"]; exists {
|
|
||||||
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
|
|
||||||
}
|
|
||||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
|
||||||
t.Fatalf("sandbox mode changed to %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/testutil"
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
@@ -112,11 +111,7 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
|||||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||||
|
|
||||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||||
testutil.WaitFor(t, "worker idle", func() bool {
|
waitForIdle(t, diagnostics)
|
||||||
diagnostics.mu.Lock()
|
|
||||||
defer diagnostics.mu.Unlock()
|
|
||||||
return diagnostics.working == nil
|
|
||||||
})
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
response = "ERR new"
|
response = "ERR new"
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -128,6 +123,23 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
busy := diagnostics.working
|
||||||
|
diagnostics.mu.Unlock()
|
||||||
|
if busy == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("worker never went idle")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||||
// touching the filesystem.
|
// touching the filesystem.
|
||||||
type changedFilesTool struct{}
|
type changedFilesTool struct{}
|
||||||
|
|||||||
@@ -501,3 +501,15 @@ func hasStringPrefix(values []string, prefix []string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func equalStringSlices(left []string, right []string) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range left {
|
||||||
|
if left[index] != right[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -99,43 +99,6 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The launcher denylist is only worth anything if it holds on the path a user
|
|
||||||
// actually sees. A model naming its own prefix_rule is that path: before
|
|
||||||
// launcher names were normalized, "python3.11" was offered as a one-token
|
|
||||||
// grant, and hasStringPrefix then matched every later python3.11 invocation.
|
|
||||||
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
|
|
||||||
for _, testCase := range []struct {
|
|
||||||
command string
|
|
||||||
rule []any
|
|
||||||
}{
|
|
||||||
{"python3.11 script.py", []any{"python3.11"}},
|
|
||||||
{"python.exe script.py", []any{"python.exe"}},
|
|
||||||
{"node.exe app.js", []any{"node.exe"}},
|
|
||||||
{"cmd /c whoami", []any{"cmd"}},
|
|
||||||
{"busybox sh -c id", []any{"busybox"}},
|
|
||||||
{"uv run main.py", []any{"uv", "run"}},
|
|
||||||
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
|
|
||||||
} {
|
|
||||||
got := proposedCommandPrefix("bash", map[string]any{
|
|
||||||
"command": testCase.command,
|
|
||||||
"prefix_rule": testCase.rule,
|
|
||||||
})
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
|
|
||||||
got := proposedCommandPrefix("bash", map[string]any{
|
|
||||||
"command": "cargo build --release",
|
|
||||||
"prefix_rule": []any{"cargo", "build"},
|
|
||||||
})
|
|
||||||
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
|
|
||||||
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"cat < in > out",
|
"cat < in > out",
|
||||||
|
|||||||
+31
-276
@@ -4,9 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/trace"
|
"github.com/Gitlawb/zero/internal/trace"
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
@@ -40,9 +38,8 @@ const compactionTriggerRatio = 0.7
|
|||||||
// transcript (and so tests can assert on it).
|
// transcript (and so tests can assert on it).
|
||||||
const summaryLabel = "[Summary of earlier conversation]"
|
const summaryLabel = "[Summary of earlier conversation]"
|
||||||
|
|
||||||
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
// summaryInstructions is the system prompt handed to the summarizer model.
|
||||||
// and manually requested compaction summarizers.
|
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||||
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
|
||||||
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
|
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
|
||||||
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
||||||
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
||||||
@@ -59,10 +56,6 @@ type CompactionOptions struct {
|
|||||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||||
// real provider call.
|
// real provider call.
|
||||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||||
// taskState is a snapshot supplied by the running agent. Its immutable
|
|
||||||
// objective is always preserved; mutable fields are admitted only when its
|
|
||||||
// plan projection still matches the transcript.
|
|
||||||
taskState *taskStateSnapshot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||||
@@ -78,12 +71,6 @@ type CompactionResult struct {
|
|||||||
// summary message also includes summaryLabel and any preserved structured
|
// summary message also includes summaryLabel and any preserved structured
|
||||||
// state needed by later compactions.
|
// state needed by later compactions.
|
||||||
SummaryText string
|
SummaryText string
|
||||||
// ProjectedChars is the text content sent to the summarizer after semantic
|
|
||||||
// projection, excluding provider protocol framing.
|
|
||||||
ProjectedChars int
|
|
||||||
// Truncated reports whether projection dropped content to satisfy its
|
|
||||||
// bounded brief or previous-summary limits.
|
|
||||||
Truncated bool
|
|
||||||
// Compacted reports whether Messages contains an injected summary.
|
// Compacted reports whether Messages contains an injected summary.
|
||||||
Compacted bool
|
Compacted bool
|
||||||
}
|
}
|
||||||
@@ -146,17 +133,11 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
|||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
total += ApproxTextTokens(tool.Name)
|
total += ApproxTextTokens(tool.Name)
|
||||||
total += ApproxTextTokens(tool.Description)
|
total += ApproxTextTokens(tool.Description)
|
||||||
total += ApproxTextTokens(string(tool.Type))
|
|
||||||
if len(tool.Parameters) > 0 {
|
if len(tool.Parameters) > 0 {
|
||||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||||
total += ApproxTextTokens(string(encoded))
|
total += ApproxTextTokens(string(encoded))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tool.Format != nil {
|
|
||||||
total += ApproxTextTokens(tool.Format.Type)
|
|
||||||
total += ApproxTextTokens(tool.Format.Syntax)
|
|
||||||
total += ApproxTextTokens(tool.Format.Definition)
|
|
||||||
}
|
|
||||||
total += 4 // per-tool overhead
|
total += 4 // per-tool overhead
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
@@ -231,15 +212,15 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
summary, err := opts.Summarize(middle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CompactionResult{}, err
|
return CompactionResult{}, err
|
||||||
}
|
}
|
||||||
summary := summaryResult.SummaryText
|
summary = strings.TrimSpace(summary)
|
||||||
|
|
||||||
// Preserve structured state (active plan + loaded skills) from the elided
|
// Preserve structured state (active plan + loaded skills) from the elided
|
||||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
||||||
|
|
||||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||||
compacted = append(compacted, messages[:systemEnd]...)
|
compacted = append(compacted, messages[:systemEnd]...)
|
||||||
@@ -253,53 +234,10 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
|||||||
RemovedCount: len(middle),
|
RemovedCount: len(middle),
|
||||||
PreservedCount: len(messages) - len(middle),
|
PreservedCount: len(messages) - len(middle),
|
||||||
SummaryText: summary,
|
SummaryText: summary,
|
||||||
ProjectedChars: summaryResult.ProjectedChars,
|
|
||||||
Truncated: summaryResult.Truncated,
|
|
||||||
Compacted: true,
|
Compacted: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompactionSummaryResult describes the shared summarization input and output.
|
|
||||||
type CompactionSummaryResult struct {
|
|
||||||
SummaryText string
|
|
||||||
ProjectedChars int
|
|
||||||
Truncated bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// SummarizeCompactionMessages applies the shared semantic projection before
|
|
||||||
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
|
|
||||||
// and manual session compaction both use this path.
|
|
||||||
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
|
|
||||||
if summarize == nil {
|
|
||||||
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
|
|
||||||
}
|
|
||||||
projection := projectCompactionInput(messages)
|
|
||||||
summaryInput := projection.messages
|
|
||||||
if len(summaryInput) == 0 {
|
|
||||||
summaryInput = messages
|
|
||||||
}
|
|
||||||
summary, err := summarize(summaryInput)
|
|
||||||
if err != nil {
|
|
||||||
return CompactionSummaryResult{}, err
|
|
||||||
}
|
|
||||||
return CompactionSummaryResult{
|
|
||||||
SummaryText: strings.TrimSpace(summary),
|
|
||||||
ProjectedChars: compactionMessageChars(summaryInput),
|
|
||||||
Truncated: projection.truncated,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactionMessageChars(messages []zeroruntime.Message) int {
|
|
||||||
total := 0
|
|
||||||
for _, message := range messages {
|
|
||||||
total += len(message.Content)
|
|
||||||
for _, call := range message.ToolCalls {
|
|
||||||
total += len(call.Name) + len(call.Arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
||||||
// the preserved suffix begins on a user or assistant message rather than a
|
// the preserved suffix begins on a user or assistant message rather than a
|
||||||
// tool/tool_result message. A tool result with no preceding assistant tool call
|
// tool/tool_result message. A tool result with no preceding assistant tool call
|
||||||
@@ -388,9 +326,6 @@ type compactionState struct {
|
|||||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||||
// but its token COST must still be counted so usage reports and budgets include it.
|
// but its token COST must still be counted so usage reports and budgets include it.
|
||||||
onUsage func(Usage)
|
onUsage func(Usage)
|
||||||
task *taskState
|
|
||||||
planner *contextPlanner
|
|
||||||
trace *trace.Recorder
|
|
||||||
|
|
||||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||||
@@ -398,27 +333,6 @@ type compactionState struct {
|
|||||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
// 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.
|
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||||
calibrationRatio float64
|
calibrationRatio float64
|
||||||
|
|
||||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
|
||||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
|
||||||
// memoizes the built provider and summarizerResolved records that the
|
|
||||||
// factory ran (it may legitimately return no provider: the main model is
|
|
||||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
|
||||||
// build or call fails, the run stays on the main provider until the next
|
|
||||||
// model switch — a misconfigured cheap model must never break (or
|
|
||||||
// repeatedly delay) compaction.
|
|
||||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
|
||||||
summarizerProvider Provider
|
|
||||||
summarizerResolved bool
|
|
||||||
summarizerBroken bool
|
|
||||||
// window is the context window the threshold was derived from. The
|
|
||||||
// reactive free-prune stage compares against it (the limit the provider
|
|
||||||
// enforces), not against the 70% proactive threshold — a pruned history
|
|
||||||
// between the two would still succeed on retry.
|
|
||||||
window int
|
|
||||||
// mainModel is the model the run currently streams on; the summarizer
|
|
||||||
// choice is made against it and remade after a mid-run switch.
|
|
||||||
mainModel string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||||
@@ -451,88 +365,13 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
|||||||
return int(float64(raw) * state.calibrationRatio)
|
return int(float64(raw) * state.calibrationRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
func newCompactionState(options Options) *compactionState {
|
||||||
state := &compactionState{
|
return &compactionState{
|
||||||
enabled: options.ContextWindow > 0,
|
enabled: options.ContextWindow > 0,
|
||||||
threshold: compactionThreshold(options.ContextWindow),
|
threshold: compactionThreshold(options.ContextWindow),
|
||||||
window: options.ContextWindow,
|
preserveLast: options.CompactionPreserveLast,
|
||||||
mainModel: options.Model,
|
onUsage: options.OnUsage,
|
||||||
preserveLast: options.CompactionPreserveLast,
|
|
||||||
onUsage: options.OnUsage,
|
|
||||||
task: task,
|
|
||||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
|
||||||
trace: options.Trace,
|
|
||||||
summarizerFactory: options.Summarizer,
|
|
||||||
}
|
}
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
|
||||||
// switch. The low-water mark is reset: it was measured against the old
|
|
||||||
// threshold, and keeping it could suppress a compaction the smaller window
|
|
||||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
|
||||||
func (state *compactionState) updateWindow(window int) {
|
|
||||||
if window <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state.enabled = true
|
|
||||||
state.window = window
|
|
||||||
state.threshold = compactionThreshold(window)
|
|
||||||
state.lowWaterMark = 0
|
|
||||||
if state.planner != nil {
|
|
||||||
state.planner.config.contextWindow = window
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// switchModel records a mid-run model switch: the window is re-derived
|
|
||||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
|
||||||
// the next compaction re-resolves it against the new main model. A run that
|
|
||||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
|
||||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
|
||||||
// dedicated summarizer exists for.
|
|
||||||
func (state *compactionState) switchModel(modelID string, window int) {
|
|
||||||
state.mainModel = modelID
|
|
||||||
state.updateWindow(window)
|
|
||||||
state.summarizerProvider = nil
|
|
||||||
state.summarizerResolved = false
|
|
||||||
state.summarizerBroken = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// summarizeProvider returns the provider summarization calls should use and
|
|
||||||
// whether it is the dedicated summarizer: that one when it applies to the
|
|
||||||
// current main model and is healthy, else the main provider. The factory runs
|
|
||||||
// at most once per main model; a build error marks the summarizer broken for
|
|
||||||
// that model. Callers branch on the boolean rather than comparing Provider
|
|
||||||
// values — interface comparison panics on a non-comparable dynamic type.
|
|
||||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
|
||||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
|
||||||
return main, false
|
|
||||||
}
|
|
||||||
if !state.summarizerResolved {
|
|
||||||
state.summarizerResolved = true
|
|
||||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
|
||||||
if err != nil {
|
|
||||||
state.summarizerBroken = true
|
|
||||||
return main, false
|
|
||||||
}
|
|
||||||
state.summarizerProvider = built
|
|
||||||
}
|
|
||||||
if state.summarizerProvider == nil {
|
|
||||||
return main, false
|
|
||||||
}
|
|
||||||
return state.summarizerProvider, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
|
||||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
|
||||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
|
||||||
// against the window itself, with a small margin for estimate error, so a
|
|
||||||
// history pruned to, say, 85% of the window takes the free retry.
|
|
||||||
func (state *compactionState) fitsWindow(size int) bool {
|
|
||||||
if state.window <= 0 {
|
|
||||||
return size <= state.threshold
|
|
||||||
}
|
|
||||||
return size <= state.window-state.window/20
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||||
@@ -567,14 +406,6 @@ func (state *compactionState) maybeCompact(
|
|||||||
// the bodies of old, large tool results (the model has already acted on
|
// the bodies of old, large tool results (the model has already acted on
|
||||||
// them). If that brings us back under threshold, skip the paid summarizer
|
// them). If that brings us back under threshold, skip the paid summarizer
|
||||||
// entirely and preserve recent turns verbatim.
|
// entirely and preserve recent turns verbatim.
|
||||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
|
||||||
messages = pruned
|
|
||||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
|
||||||
if size <= state.threshold {
|
|
||||||
state.lowWaterMark = size
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||||
messages = pruned
|
messages = pruned
|
||||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||||
@@ -586,8 +417,7 @@ func (state *compactionState) maybeCompact(
|
|||||||
|
|
||||||
compacted, err := Compact(messages, CompactionOptions{
|
compacted, err := Compact(messages, CompactionOptions{
|
||||||
PreserveLast: state.preserveLast,
|
PreserveLast: state.preserveLast,
|
||||||
Summarize: state.summarizeClosure(ctx, provider),
|
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||||
taskState: state.task.snapshotForCompaction(messages),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Summarizer failed: keep the original history. The reactive path (or a
|
// Summarizer failed: keep the original history. The reactive path (or a
|
||||||
@@ -628,41 +458,16 @@ func (state *compactionState) recover(
|
|||||||
// context-limit error never triggers an unexpected summarization call.
|
// context-limit error never triggers an unexpected summarization call.
|
||||||
return messages, false, nil
|
return messages, false, nil
|
||||||
}
|
}
|
||||||
if !isContextLimitError(errorMessage) {
|
if state.reactiveAttempted {
|
||||||
return messages, false, nil
|
return messages, false, nil
|
||||||
}
|
}
|
||||||
|
if !isContextLimitError(errorMessage) {
|
||||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
|
||||||
// often be fixed for free by dropping superseded reads and pruning old
|
|
||||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
|
||||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
|
||||||
// prune). The free retry is taken only when the pruned history fits the
|
|
||||||
// window the provider enforces — otherwise a retry would fail the same
|
|
||||||
// way, so fall through to the paid summarizer in this same call.
|
|
||||||
toolTokens := estimateToolDefTokens(tools)
|
|
||||||
prunedAny := false
|
|
||||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
|
||||||
messages, prunedAny = pruned, true
|
|
||||||
}
|
|
||||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
|
||||||
messages, prunedAny = pruned, true
|
|
||||||
}
|
|
||||||
if prunedAny {
|
|
||||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
|
||||||
if state.fitsWindow(size) {
|
|
||||||
state.lowWaterMark = size
|
|
||||||
return messages, true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.reactiveAttempted {
|
|
||||||
return messages, false, nil
|
return messages, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, compactErr := Compact(messages, CompactionOptions{
|
result, compactErr := Compact(messages, CompactionOptions{
|
||||||
PreserveLast: state.preserveLast,
|
PreserveLast: state.preserveLast,
|
||||||
Summarize: state.summarizeClosure(ctx, provider),
|
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||||
taskState: state.task.snapshotForCompaction(messages),
|
|
||||||
})
|
})
|
||||||
if compactErr != nil {
|
if compactErr != nil {
|
||||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||||
@@ -695,22 +500,9 @@ func (state *compactionState) recover(
|
|||||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
||||||
// call fails the summarizer is marked broken for the current model and the
|
|
||||||
// same slice is retried once on the main provider, so the caller sees at most
|
|
||||||
// one (main-provider-quality) error.
|
|
||||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
|
||||||
if state.planner == nil {
|
|
||||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
|
||||||
}
|
|
||||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
||||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
|
||||||
if err == nil || !dedicated {
|
|
||||||
return summary, err
|
|
||||||
}
|
|
||||||
state.summarizerBroken = true
|
|
||||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,11 +514,7 @@ func (state *compactionState) summarizeClosure(ctx context.Context, provider Pro
|
|||||||
// Non-context-limit errors (and a single message that still won't fit) surface
|
// Non-context-limit errors (and a single message that still won't fit) surface
|
||||||
// to the caller unchanged.
|
// to the caller unchanged.
|
||||||
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||||
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
|
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
||||||
}
|
|
||||||
|
|
||||||
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
|
||||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
@@ -735,11 +523,11 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
|||||||
}
|
}
|
||||||
|
|
||||||
mid := len(messages) / 2
|
mid := len(messages) / 2
|
||||||
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
||||||
if leftErr != nil {
|
if leftErr != nil {
|
||||||
return "", leftErr
|
return "", leftErr
|
||||||
}
|
}
|
||||||
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
||||||
if rightErr != nil {
|
if rightErr != nil {
|
||||||
return "", rightErr
|
return "", rightErr
|
||||||
}
|
}
|
||||||
@@ -752,7 +540,7 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
|||||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||||
}, onUsage, planner, recorder)
|
}, onUsage)
|
||||||
if reduceErr != nil {
|
if reduceErr != nil {
|
||||||
if isContextLimitError(reduceErr.Error()) {
|
if isContextLimitError(reduceErr.Error()) {
|
||||||
// Even the two combined partials don't fit (extreme): fall back to the
|
// Even the two combined partials don't fit (extreme): fall back to the
|
||||||
@@ -768,15 +556,15 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||||
requestMessages := []zeroruntime.Message{
|
request := zeroruntime.CompletionRequest{
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
Messages: []zeroruntime.Message{
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||||
|
},
|
||||||
|
// No tools: this is a plain text summarization call.
|
||||||
}
|
}
|
||||||
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
stream, err := provider.StreamCompletion(ctx, request)
|
||||||
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
|
||||||
recordContextPlanTrace(recorder, plan)
|
|
||||||
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -793,41 +581,8 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
|
||||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
|
||||||
// search results), yet the summary instructions only need their gist — the
|
|
||||||
// model that ACTED on the full output already turned it into decisions the
|
|
||||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
|
||||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
|
||||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
|
||||||
const summaryToolResultClamp = 2000
|
|
||||||
|
|
||||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
|
||||||
// a write_file call carries the whole file body in its arguments.
|
|
||||||
const summaryToolArgsClamp = 500
|
|
||||||
|
|
||||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
|
||||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
|
||||||
func clampForSummary(text string, max int) string {
|
|
||||||
if max <= 0 || len(text) <= max {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
head := max * 3 / 4
|
|
||||||
tail := max - head
|
|
||||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
|
||||||
head--
|
|
||||||
}
|
|
||||||
start := len(text) - tail
|
|
||||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
|
||||||
start++
|
|
||||||
}
|
|
||||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderTranscript flattens messages into a plain-text transcript for the
|
// renderTranscript flattens messages into a plain-text transcript for the
|
||||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
|
||||||
// above): the summary needs what was done and decided, not the raw payloads.
|
|
||||||
func renderTranscript(messages []zeroruntime.Message) string {
|
func renderTranscript(messages []zeroruntime.Message) string {
|
||||||
lines := make([]string, 0, len(messages))
|
lines := make([]string, 0, len(messages))
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
@@ -837,13 +592,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
|||||||
if len(message.ToolCalls) > 0 {
|
if len(message.ToolCalls) > 0 {
|
||||||
calls := make([]string, 0, len(message.ToolCalls))
|
calls := make([]string, 0, len(message.ToolCalls))
|
||||||
for _, call := range message.ToolCalls {
|
for _, call := range message.ToolCalls {
|
||||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||||
}
|
}
|
||||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
case zeroruntime.MessageRoleTool:
|
case zeroruntime.MessageRoleTool:
|
||||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
lines = append(lines, "tool result: "+message.Content)
|
||||||
default:
|
default:
|
||||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,469 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// compactableHistory returns a history whose middle is large enough to trip
|
|
||||||
// the proactive threshold for a 1000-token window.
|
|
||||||
func compactableHistory() []zeroruntime.Message {
|
|
||||||
return []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
|
||||||
// configured — the main provider sees no summarize traffic at all.
|
|
||||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
|
||||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
|
||||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
}}}
|
|
||||||
main := &mockProvider{}
|
|
||||||
factoryCalls := 0
|
|
||||||
state := newCompactionState(Options{
|
|
||||||
ContextWindow: 1000,
|
|
||||||
CompactionPreserveLast: 2,
|
|
||||||
Summarizer: func(context.Context, string) (Provider, error) {
|
|
||||||
factoryCalls++
|
|
||||||
return cheap, nil
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
|
||||||
|
|
||||||
if len(cheap.requests) == 0 {
|
|
||||||
t.Fatal("dedicated summarizer never received the summarize call")
|
|
||||||
}
|
|
||||||
if len(main.requests) != 0 {
|
|
||||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
|
||||||
}
|
|
||||||
if factoryCalls != 1 {
|
|
||||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
|
||||||
}
|
|
||||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
|
||||||
t.Fatal("compaction did not shrink the history")
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, message := range compacted {
|
|
||||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
|
||||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
|
||||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
|
||||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
|
||||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
|
||||||
}}}
|
|
||||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
||||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
|
||||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
|
||||||
}}
|
|
||||||
state := newCompactionState(Options{
|
|
||||||
ContextWindow: 1000,
|
|
||||||
CompactionPreserveLast: 2,
|
|
||||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
|
||||||
}, nil)
|
|
||||||
summarize := state.summarizeClosure(context.Background(), main)
|
|
||||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
|
||||||
|
|
||||||
summary, err := summarize(input)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
|
||||||
}
|
|
||||||
if summary != "MAIN SUMMARY" {
|
|
||||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
|
||||||
}
|
|
||||||
if len(broken.requests) != 1 {
|
|
||||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
|
||||||
}
|
|
||||||
if !state.summarizerBroken {
|
|
||||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second summarization: straight to main, no cheap retry.
|
|
||||||
if _, err := summarize(input); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(broken.requests) != 1 {
|
|
||||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
|
||||||
}
|
|
||||||
if len(main.requests) != 2 {
|
|
||||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
|
||||||
// main provider used, factory not retried.
|
|
||||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
|
||||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
|
||||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
}}}
|
|
||||||
factoryCalls := 0
|
|
||||||
state := newCompactionState(Options{
|
|
||||||
ContextWindow: 1000,
|
|
||||||
CompactionPreserveLast: 2,
|
|
||||||
Summarizer: func(context.Context, string) (Provider, error) {
|
|
||||||
factoryCalls++
|
|
||||||
return nil, errors.New("unknown model")
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
summarize := state.summarizeClosure(context.Background(), main)
|
|
||||||
|
|
||||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
|
||||||
if err != nil || summary != "MAIN SUMMARY" {
|
|
||||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
|
||||||
}
|
|
||||||
if factoryCalls != 1 {
|
|
||||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
|
||||||
}
|
|
||||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
|
||||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
|
||||||
}
|
|
||||||
if factoryCalls != 1 {
|
|
||||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool payloads are clamped in the summarizer transcript: results to
|
|
||||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
|
||||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
|
||||||
bigResult := strings.Repeat("r", 50_000)
|
|
||||||
bigArgs := strings.Repeat("w", 20_000)
|
|
||||||
transcript := renderTranscript([]zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
|
||||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
|
||||||
}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
|
||||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
|
||||||
}
|
|
||||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
|
||||||
t.Fatal("clamped transcript must carry the omission marker")
|
|
||||||
}
|
|
||||||
if !strings.Contains(transcript, "keep going") {
|
|
||||||
t.Fatal("non-tool content must survive unclamped")
|
|
||||||
}
|
|
||||||
// Head AND tail of the clamped payloads survive.
|
|
||||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
|
||||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
|
||||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
|
||||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
|
||||||
// the older ones are prunable.
|
|
||||||
func bigToolHistory(n int) []zeroruntime.Message {
|
|
||||||
bigBody := strings.Repeat("x", 160_000)
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
id := string(rune('a' + i))
|
|
||||||
messages = append(messages,
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func summaryProvider(text string) *mockProvider {
|
|
||||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
||||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
|
||||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A context-limit error is first answered with the free prune stage; when the
|
|
||||||
// pruned history fits the window the retry is free (no summarizer call) and
|
|
||||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
|
||||||
// when a later error finds nothing left to prune.
|
|
||||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
|
||||||
provider := summaryProvider("SUMMARY")
|
|
||||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
|
||||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
|
||||||
messages := bigToolHistory(5)
|
|
||||||
|
|
||||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !retried {
|
|
||||||
t.Fatal("prunable history that fits must produce a free retry")
|
|
||||||
}
|
|
||||||
if len(provider.requests) != 0 {
|
|
||||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
|
||||||
}
|
|
||||||
if state.reactiveAttempted {
|
|
||||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
|
||||||
}
|
|
||||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
|
||||||
t.Fatal("prune did not shrink the history")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second context-limit error on the pruned history: nothing meaningfully
|
|
||||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
|
||||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !retried {
|
|
||||||
t.Fatal("second recover must compact and retry")
|
|
||||||
}
|
|
||||||
if len(provider.requests) == 0 {
|
|
||||||
t.Fatal("second recover must reach the summarizer")
|
|
||||||
}
|
|
||||||
if !state.reactiveAttempted {
|
|
||||||
t.Fatal("a paid recover must consume the reactive budget")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When pruning reclaims something but the pruned history still exceeds the
|
|
||||||
// window, recover falls through to the paid summarizer in the SAME call
|
|
||||||
// instead of spending a retry that would fail the same way.
|
|
||||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
|
||||||
provider := summaryProvider("SUMMARY")
|
|
||||||
// A 1,000-token window: even the protected trailing output overflows it.
|
|
||||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
|
||||||
messages := bigToolHistory(5)
|
|
||||||
|
|
||||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !retried {
|
|
||||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
|
||||||
}
|
|
||||||
if len(provider.requests) == 0 {
|
|
||||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
|
||||||
}
|
|
||||||
if !state.reactiveAttempted {
|
|
||||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
|
||||||
}
|
|
||||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
|
||||||
t.Fatal("recover did not shrink the history")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
|
||||||
// reactive compaction, a later context-limit error still gets a free retry
|
|
||||||
// when pruning makes the history fit.
|
|
||||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
|
||||||
provider := summaryProvider("SUMMARY")
|
|
||||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
|
||||||
state.reactiveAttempted = true
|
|
||||||
messages := bigToolHistory(5)
|
|
||||||
|
|
||||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !retried || len(provider.requests) != 0 {
|
|
||||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
|
||||||
}
|
|
||||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
|
||||||
t.Fatal("prune did not shrink the history")
|
|
||||||
}
|
|
||||||
// With nothing left to prune and the budget spent, the caller keeps its
|
|
||||||
// original error — recover neither loops nor summarizes.
|
|
||||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
|
||||||
if err != nil || retried || len(provider.requests) != 0 {
|
|
||||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The free-retry gate compares against the window the provider enforces, not
|
|
||||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
|
||||||
// takes the free retry.
|
|
||||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
|
||||||
provider := summaryProvider("SUMMARY")
|
|
||||||
messages := bigToolHistory(5)
|
|
||||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
|
||||||
prunedTokens := estimateTokens(pruned)
|
|
||||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
|
||||||
window := prunedTokens * 100 / 80
|
|
||||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
|
||||||
if prunedTokens <= state.threshold {
|
|
||||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !retried || len(provider.requests) != 0 {
|
|
||||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
|
||||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
|
||||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
|
||||||
// retried for the new model.
|
|
||||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
|
||||||
cheap := summaryProvider("CHEAP SUMMARY")
|
|
||||||
main := summaryProvider("MAIN SUMMARY")
|
|
||||||
var factoryModels []string
|
|
||||||
state := newCompactionState(Options{
|
|
||||||
ContextWindow: 1000,
|
|
||||||
CompactionPreserveLast: 2,
|
|
||||||
Model: "cheap-model",
|
|
||||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
|
||||||
factoryModels = append(factoryModels, mainModelID)
|
|
||||||
if mainModelID == "cheap-model" {
|
|
||||||
return nil, nil // main already is the cheap model
|
|
||||||
}
|
|
||||||
return cheap, nil
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
|
||||||
t.Fatal("on the cheap model the main provider must summarize")
|
|
||||||
}
|
|
||||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
|
||||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.switchModel("big-model", 400_000)
|
|
||||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
|
||||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
|
||||||
}
|
|
||||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
|
||||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
|
||||||
}
|
|
||||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
|
||||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A broken summarizer is retried after the next switch.
|
|
||||||
state.summarizerBroken = true
|
|
||||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
|
||||||
t.Fatal("a broken summarizer must fall back to main")
|
|
||||||
}
|
|
||||||
state.switchModel("other-model", 0)
|
|
||||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
|
||||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompactionUpdateWindow(t *testing.T) {
|
|
||||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
|
||||||
state.lowWaterMark = 50_000
|
|
||||||
before := state.threshold
|
|
||||||
|
|
||||||
state.updateWindow(0) // unknown model: unchanged
|
|
||||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
|
||||||
t.Fatal("updateWindow(0) must be a no-op")
|
|
||||||
}
|
|
||||||
|
|
||||||
state.updateWindow(10_000)
|
|
||||||
if state.threshold >= before {
|
|
||||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
|
||||||
}
|
|
||||||
if state.lowWaterMark != 0 {
|
|
||||||
t.Fatal("low-water mark must reset on a window change")
|
|
||||||
}
|
|
||||||
if !state.enabled {
|
|
||||||
t.Fatal("a positive window must keep compaction enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
|
||||||
// context window via ContextWindowFor, visible in the OnContext report.
|
|
||||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(escalatingTool{target: "big-model"})
|
|
||||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
|
||||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
}}}
|
|
||||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
}}}
|
|
||||||
|
|
||||||
var windows []int
|
|
||||||
result, err := Run(context.Background(), "go", provider, Options{
|
|
||||||
Registry: registry,
|
|
||||||
ContextWindow: 100_000,
|
|
||||||
Model: "small-model",
|
|
||||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
|
||||||
ContextWindowFor: func(modelID string) int {
|
|
||||||
if modelID == "big-model" {
|
|
||||||
return 400_000
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
},
|
|
||||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.FinalAnswer != "done" {
|
|
||||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
|
||||||
}
|
|
||||||
if len(windows) < 2 {
|
|
||||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
|
||||||
}
|
|
||||||
if windows[0] != 100_000 {
|
|
||||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
|
||||||
}
|
|
||||||
if windows[len(windows)-1] != 400_000 {
|
|
||||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
|
||||||
// values holding it would panic; the summarizer path must never compare them.
|
|
||||||
type nonComparableProvider struct {
|
|
||||||
inner *mockProvider
|
|
||||||
tags []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
||||||
return p.inner.StreamCompletion(ctx, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
|
||||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
|
||||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
|
||||||
}}}
|
|
||||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
|
||||||
state := newCompactionState(Options{
|
|
||||||
ContextWindow: 1000,
|
|
||||||
CompactionPreserveLast: 2,
|
|
||||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
|
||||||
}, nil)
|
|
||||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
|
||||||
if err != nil || summary != "MAIN SUMMARY" {
|
|
||||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
|
||||||
}
|
|
||||||
// And with no dedicated summarizer at all the closure still works.
|
|
||||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
|
||||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,14 +43,8 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
|||||||
if result.SummaryText != "manual summary" {
|
if result.SummaryText != "manual summary" {
|
||||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||||
}
|
}
|
||||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
|
||||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
|
||||||
}
|
|
||||||
if result.Truncated {
|
|
||||||
t.Fatal("Truncated = true for a projection within its limits")
|
|
||||||
}
|
|
||||||
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
|
|
||||||
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
|
|
||||||
}
|
}
|
||||||
if len(result.Messages) != 4 {
|
if len(result.Messages) != 4 {
|
||||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||||
@@ -102,46 +96,11 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
|
|||||||
if result.SummaryText != "" {
|
if result.SummaryText != "" {
|
||||||
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
|
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
|
||||||
}
|
}
|
||||||
if result.ProjectedChars != 0 || result.Truncated {
|
|
||||||
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(result.Messages, messages) {
|
if !reflect.DeepEqual(result.Messages, messages) {
|
||||||
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
|
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
|
|
||||||
for index := range 20 {
|
|
||||||
messages = append(messages, zeroruntime.Message{
|
|
||||||
Role: zeroruntime.MessageRoleUser,
|
|
||||||
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
messages = append(messages,
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
|
|
||||||
)
|
|
||||||
|
|
||||||
var captured []zeroruntime.Message
|
|
||||||
result, err := CompactMessages(messages, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
|
|
||||||
captured = append([]zeroruntime.Message(nil), toSummarize...)
|
|
||||||
return "bounded summary", nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !result.Compacted || !result.Truncated {
|
|
||||||
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
|
|
||||||
}
|
|
||||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
|
||||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
|
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
|
||||||
messages := []zeroruntime.Message{
|
messages := []zeroruntime.Message{
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ const (
|
|||||||
const (
|
const (
|
||||||
maxRecentEdits = 20
|
maxRecentEdits = 20
|
||||||
maxEditNoteBytes = 160
|
maxEditNoteBytes = 160
|
||||||
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
|
||||||
// compaction without allowing an unusually large prompt to recreate the
|
|
||||||
// context pressure the compaction just removed.
|
|
||||||
maxTaskObjectiveBytes = 512
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -80,14 +76,32 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
|||||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||||
// "" on malformed arguments or an empty plan.
|
// "" on malformed arguments or an empty plan.
|
||||||
func formatPlanArguments(arguments string) string {
|
func formatPlanArguments(arguments string) string {
|
||||||
plan, ok := parseTaskPlan(arguments)
|
var parsed struct {
|
||||||
if !ok {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
lines := make([]string, 0, len(plan))
|
lines := make([]string, 0, len(parsed.Plan))
|
||||||
for _, item := range plan {
|
for _, item := range parsed.Plan {
|
||||||
line := "- [" + item.Status + "] " + item.Content
|
content := strings.TrimSpace(item.Content)
|
||||||
if notes := item.Notes; notes != "" {
|
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
|
line += "\n Notes: " + notes
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
@@ -102,7 +116,7 @@ type skillEntry struct {
|
|||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentEdits returns files mutated by editing calls in messages
|
// 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:
|
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||||
// changed in each file (from the tool's result) so it need not re-read to
|
// changed in each file (from the tool's result) so it need not re-read to
|
||||||
@@ -113,7 +127,6 @@ type skillEntry struct {
|
|||||||
// rather than an earlier, now-stale entry.
|
// rather than an earlier, now-stale entry.
|
||||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||||
pathByID := map[string]string{}
|
pathByID := map[string]string{}
|
||||||
noteByPath := map[string]string{}
|
|
||||||
sequence := make([]string, 0)
|
sequence := make([]string, 0)
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
for _, call := range message.ToolCalls {
|
for _, call := range message.ToolCalls {
|
||||||
@@ -130,24 +143,12 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
|||||||
sequence = append(sequence, path)
|
sequence = append(sequence, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, message := range messages {
|
|
||||||
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, path := range message.ChangedFiles {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sequence = append(sequence, path)
|
|
||||||
noteByPath[path] = editNote(message.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
order := lastSeenOrder(sequence)
|
order := lastSeenOrder(sequence)
|
||||||
if len(order) == 0 {
|
if len(order) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noteByPath := map[string]string{}
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -430,30 +431,12 @@ func capBody(body string) string {
|
|||||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||||
type preservedState struct {
|
type preservedState struct {
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
Task *preservedTaskState `json:"task,omitempty"`
|
|
||||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||||
Tools []preservedTool `json:"tools,omitempty"`
|
Tools []preservedTool `json:"tools,omitempty"`
|
||||||
Skills []preservedSkill `json:"skills,omitempty"`
|
Skills []preservedSkill `json:"skills,omitempty"`
|
||||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type preservedTaskState struct {
|
|
||||||
Objective string `json:"objective"`
|
|
||||||
Status taskStatus `json:"status,omitempty"`
|
|
||||||
Pending int `json:"pending,omitempty"`
|
|
||||||
InProgress int `json:"in_progress,omitempty"`
|
|
||||||
Completed int `json:"completed,omitempty"`
|
|
||||||
Failed int `json:"failed,omitempty"`
|
|
||||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
|
||||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
|
||||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
|
||||||
Constraints []string `json:"constraints,omitempty"`
|
|
||||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
|
||||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
|
||||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
|
||||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type preservedEdit struct {
|
type preservedEdit struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
@@ -481,39 +464,8 @@ type preservedInstruction struct {
|
|||||||
// may live only inside the injected summary message, which on a later compaction
|
// may live only inside the injected summary message, which on a later compaction
|
||||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||||
// instruction blocks override the carried-forward copy by name/source.
|
// instruction blocks override the carried-forward copy by name/source.
|
||||||
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||||
task := priorState.Task
|
|
||||||
if taskSnapshot != nil {
|
|
||||||
task = &preservedTaskState{
|
|
||||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
|
||||||
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
|
|
||||||
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
|
|
||||||
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
|
|
||||||
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
|
|
||||||
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
|
|
||||||
}
|
|
||||||
// Plan parity corroborates only the mutable task projection. The objective
|
|
||||||
// comes directly from the run prompt and is immutable, so it must survive
|
|
||||||
// even after compaction removes the plan tool call needed for comparison.
|
|
||||||
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
|
||||||
task.Status = taskSnapshot.Status
|
|
||||||
task.Pending = taskSnapshot.Plan.Pending
|
|
||||||
task.InProgress = taskSnapshot.Plan.InProgress
|
|
||||||
task.Completed = taskSnapshot.Plan.Completed
|
|
||||||
task.Failed = taskSnapshot.Plan.Failed
|
|
||||||
task.VerificationPassed = taskSnapshot.Verification.Passed
|
|
||||||
task.VerificationFailed = taskSnapshot.Verification.Failed
|
|
||||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freshConstraints := explicitConstraintsFromMessages(middle)
|
|
||||||
if len(freshConstraints) > 0 {
|
|
||||||
if task == nil {
|
|
||||||
task = &preservedTaskState{}
|
|
||||||
}
|
|
||||||
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||||
// plan preserved by an earlier compaction.
|
// plan preserved by an earlier compaction.
|
||||||
@@ -541,114 +493,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
|||||||
projectInstructionEntries(middle),
|
projectInstructionEntries(middle),
|
||||||
)
|
)
|
||||||
|
|
||||||
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||||
summary += "\n\n" + block
|
summary += "\n\n" + block
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
|
|
||||||
var constraints []string
|
|
||||||
for _, message := range messages {
|
|
||||||
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, body := projectInstructionBlock(message.Content); body != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
|
|
||||||
}
|
|
||||||
return constraints
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorTaskConstraints(task *preservedTaskState) []string {
|
|
||||||
if task == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return task.Constraints
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorTaskChangedFiles(task *preservedTaskState) []string {
|
|
||||||
if task == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return task.ChangedFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
|
|
||||||
if task == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return task.UnresolvedFailures
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
|
|
||||||
if task == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return task.Approvals
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
|
|
||||||
if task == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return task.Artifacts
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
|
|
||||||
resolvedSet := make(map[string]struct{}, len(resolved))
|
|
||||||
for _, key := range resolved {
|
|
||||||
resolvedSet[key] = struct{}{}
|
|
||||||
}
|
|
||||||
merged := make([]taskFailureState, 0, len(older)+len(newer))
|
|
||||||
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
|
|
||||||
if _, ok := resolvedSet[failure.Key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
withoutSameKey := make([]taskFailureState, 0, len(merged))
|
|
||||||
for _, existing := range merged {
|
|
||||||
if existing.Key != failure.Key {
|
|
||||||
withoutSameKey = append(withoutSameKey, existing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged = append(withoutSameKey, failure)
|
|
||||||
}
|
|
||||||
if len(merged) > maxTaskEvidenceEntries {
|
|
||||||
merged = merged[len(merged)-maxTaskEvidenceEntries:]
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
|
|
||||||
merged := make([]T, 0, len(older)+len(newer))
|
|
||||||
seen := make(map[T]struct{}, len(older)+len(newer))
|
|
||||||
for _, value := range append(append([]T(nil), older...), newer...) {
|
|
||||||
if _, ok := seen[value]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
merged = append(merged, value)
|
|
||||||
}
|
|
||||||
if len(merged) > limit {
|
|
||||||
merged = merged[len(merged)-limit:]
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
func capTaskObjective(objective string) string {
|
|
||||||
objective = strings.TrimSpace(objective)
|
|
||||||
if len(objective) <= maxTaskObjectiveBytes {
|
|
||||||
return objective
|
|
||||||
}
|
|
||||||
const suffix = "…"
|
|
||||||
limit := maxTaskObjectiveBytes - len(suffix)
|
|
||||||
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
|
||||||
limit--
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(objective[:limit]) + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||||
@@ -705,11 +555,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
|||||||
|
|
||||||
// formatPreservedState renders state as the labelled, single-line
|
// formatPreservedState renders state as the labelled, single-line
|
||||||
// JSON block. Returns "" when there is nothing to preserve.
|
// JSON block. Returns "" when there is nothing to preserve.
|
||||||
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||||
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
state := preservedState{Plan: plan, Task: task}
|
state := preservedState{Plan: plan}
|
||||||
for _, e := range edits {
|
for _, e := range edits {
|
||||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||||
}
|
}
|
||||||
@@ -729,6 +579,15 @@ func formatPreservedState(plan string, task *preservedTaskState, edits, tools, s
|
|||||||
return preservedStateLabel + "\n" + string(encoded)
|
return preservedStateLabel + "\n" + string(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||||
|
// block. JSON escaping makes this lossless even when a skill body contains
|
||||||
|
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||||
|
// malformed.
|
||||||
|
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||||
|
state := parsePreservedStateBlock(summaryContent)
|
||||||
|
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||||
|
}
|
||||||
|
|
||||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,169 +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"}]}`})
|
|
||||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
|
|
||||||
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
|
|
||||||
}})
|
|
||||||
messages := stateConversation()
|
|
||||||
compacted, err := Compact(messages, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
|
||||||
taskState: task.snapshotForCompaction(messages),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Compact: %v", err)
|
|
||||||
}
|
|
||||||
state := parsePreservedStateBlock(compacted[1].Content)
|
|
||||||
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
|
||||||
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
|
||||||
}
|
|
||||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
|
||||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
|
||||||
}
|
|
||||||
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
|
|
||||||
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
|
|
||||||
task := newTaskState("Please keep the change focused.", nil)
|
|
||||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
|
||||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
|
|
||||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
|
||||||
}})
|
|
||||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
|
||||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
|
|
||||||
}})
|
|
||||||
messages := stateConversation()
|
|
||||||
|
|
||||||
first, err := Compact(messages, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
|
||||||
taskState: task.snapshotForCompaction(messages),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
|
||||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
|
|
||||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
|
||||||
}})
|
|
||||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
|
||||||
)
|
|
||||||
second, err := Compact(secondInput, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
|
||||||
taskState: task.snapshotForCompaction(secondInput),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
state := parsePreservedStateBlock(second[1].Content)
|
|
||||||
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
|
|
||||||
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
|
|
||||||
}
|
|
||||||
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
|
|
||||||
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
|
|
||||||
if !containsString(state.Task.Constraints, want) {
|
|
||||||
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsString(values []string, want string) bool {
|
|
||||||
for _, value := range values {
|
|
||||||
if value == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
|
||||||
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
|
||||||
encoded, err := json.Marshal(prior)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
|
||||||
}
|
|
||||||
compacted, err := Compact(messages, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
|
||||||
taskState: &taskStateSnapshot{
|
|
||||||
Objective: "current objective",
|
|
||||||
PlanParity: taskPlanParityMismatch,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Compact: %v", err)
|
|
||||||
}
|
|
||||||
state := parsePreservedStateBlock(compacted[1].Content)
|
|
||||||
if state.Task == nil || state.Task.Objective != "current objective" {
|
|
||||||
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
|
||||||
}
|
|
||||||
if state.Task.Status != "" || state.Task.Pending != 0 {
|
|
||||||
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
|
||||||
task := newTaskState("keep this objective", nil)
|
|
||||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
|
||||||
messages := stateConversation()
|
|
||||||
|
|
||||||
first, err := Compact(messages, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
|
||||||
taskState: task.snapshotForCompaction(messages),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first Compact: %v", err)
|
|
||||||
}
|
|
||||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
|
||||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
|
||||||
)
|
|
||||||
snapshot := task.snapshotForCompaction(secondInput)
|
|
||||||
if snapshot.PlanParity != taskPlanParityMismatch {
|
|
||||||
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
|
||||||
}
|
|
||||||
second, err := Compact(secondInput, CompactionOptions{
|
|
||||||
PreserveLast: 2,
|
|
||||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
|
||||||
taskState: snapshot,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second Compact: %v", err)
|
|
||||||
}
|
|
||||||
state := parsePreservedStateBlock(second[1].Content)
|
|
||||||
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
|
||||||
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
|
||||||
}
|
|
||||||
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
|
||||||
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||||
summary := compactStateConversation(t, stateConversation())
|
summary := compactStateConversation(t, stateConversation())
|
||||||
if !strings.Contains(summary, preservedStateLabel) {
|
if !strings.Contains(summary, preservedStateLabel) {
|
||||||
@@ -448,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"}]}`)
|
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||||
if got != "" {
|
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
||||||
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
compactionUserWordBudget = 256
|
|
||||||
compactionAssistantWordBudget = 200
|
|
||||||
compactionToolCallsPerTurn = 8
|
|
||||||
compactionToolArgumentBytes = 120
|
|
||||||
compactionErrorBytes = 150
|
|
||||||
compactionResultBytes = 1200
|
|
||||||
compactionPreviousSummaryBytes = 16 * 1024
|
|
||||||
compactionBriefMaxBytes = 24 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
|
||||||
|
|
||||||
type compactionProjection struct {
|
|
||||||
messages []zeroruntime.Message
|
|
||||||
truncated bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// projectCompactionInput removes reconstructible tool output before the model
|
|
||||||
// summary call. It retains a bounded transcript of user intent, assistant
|
|
||||||
// decisions, tool actions, and concise errors. Exact durable state is appended
|
|
||||||
// separately from the original messages after the summary returns.
|
|
||||||
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
|
|
||||||
sections := make([]string, 0, len(messages))
|
|
||||||
previousSummary := ""
|
|
||||||
truncated := false
|
|
||||||
for index, message := range messages {
|
|
||||||
switch message.Role {
|
|
||||||
case zeroruntime.MessageRoleUser:
|
|
||||||
content := message.Content
|
|
||||||
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
|
|
||||||
content = content[:marker]
|
|
||||||
}
|
|
||||||
trimmed := strings.TrimSpace(content)
|
|
||||||
if strings.HasPrefix(trimmed, summaryLabel) {
|
|
||||||
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
|
|
||||||
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
|
|
||||||
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
|
|
||||||
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
|
|
||||||
}
|
|
||||||
case zeroruntime.MessageRoleAssistant:
|
|
||||||
lines := make([]string, 0, 1+len(message.ToolCalls))
|
|
||||||
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
|
|
||||||
lines = append(lines, content)
|
|
||||||
}
|
|
||||||
calls := message.ToolCalls
|
|
||||||
omitted := 0
|
|
||||||
if len(calls) > compactionToolCallsPerTurn {
|
|
||||||
omitted = len(calls) - compactionToolCallsPerTurn
|
|
||||||
calls = calls[omitted:]
|
|
||||||
}
|
|
||||||
if omitted > 0 {
|
|
||||||
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
|
|
||||||
}
|
|
||||||
for _, call := range calls {
|
|
||||||
lines = append(lines, compactionToolCallLine(call))
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
|
|
||||||
}
|
|
||||||
case zeroruntime.MessageRoleTool:
|
|
||||||
// New tool-result messages carry the execution status directly, as the
|
|
||||||
// source ToolResult does. Keep the text check for older session history
|
|
||||||
// created before Message exposed IsError.
|
|
||||||
name := toolNameForResult(messages, index)
|
|
||||||
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
|
|
||||||
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
name = "tool"
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case message.IsError || isLikelyToolError(message.Content):
|
|
||||||
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
|
||||||
case name == "ask_user":
|
|
||||||
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
|
|
||||||
default:
|
|
||||||
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
|
|
||||||
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(sections) == 0 && previousSummary == "" {
|
|
||||||
return compactionProjection{}
|
|
||||||
}
|
|
||||||
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
|
|
||||||
truncated = truncated || briefTruncated
|
|
||||||
if previousSummary != "" {
|
|
||||||
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
|
|
||||||
}
|
|
||||||
return compactionProjection{
|
|
||||||
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
|
|
||||||
truncated: truncated,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func capCompactionBrief(brief string) (string, bool) {
|
|
||||||
if len(brief) <= compactionBriefMaxBytes {
|
|
||||||
return brief, false
|
|
||||||
}
|
|
||||||
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
|
|
||||||
available := compactionBriefMaxBytes - len(marker)
|
|
||||||
// Keep both chronological edges: the oldest material has not necessarily
|
|
||||||
// been summarized before, while the newest material is most actionable.
|
|
||||||
headBytes := available * 2 / 5
|
|
||||||
tailBytes := available - headBytes
|
|
||||||
head := clipPrefixAtBoundary(brief, headBytes)
|
|
||||||
tail := clipSuffixAtBoundary(brief, tailBytes)
|
|
||||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
|
||||||
detail := ""
|
|
||||||
if call.Freeform && call.Name == "apply_patch" {
|
|
||||||
detail = strings.Join(patchPaths(call.Arguments), ", ")
|
|
||||||
}
|
|
||||||
var arguments map[string]any
|
|
||||||
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
|
||||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
|
||||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
||||||
detail = value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if detail == "" && call.Name == "ask_user" {
|
|
||||||
detail = askUserQuestionsDetail(arguments)
|
|
||||||
}
|
|
||||||
if detail == "" && call.Name == "apply_patch" {
|
|
||||||
if patch, ok := arguments["patch"].(string); ok {
|
|
||||||
detail = strings.Join(patchPaths(patch), ", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if detail == "" {
|
|
||||||
detail = commandFromArguments(call.Arguments)
|
|
||||||
}
|
|
||||||
if detail == "" {
|
|
||||||
return "* " + call.Name
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLikelyToolError(content string) bool {
|
|
||||||
prefix := strings.TrimSpace(content)
|
|
||||||
if len(prefix) > compactionErrorBytes {
|
|
||||||
prefix = prefix[:compactionErrorBytes]
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(prefix)
|
|
||||||
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
|
|
||||||
if strings.HasPrefix(lower, prefix) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipInformativeWords(text string, limit int) string {
|
|
||||||
flat := strings.Join(strings.Fields(text), " ")
|
|
||||||
if flat == "" || limit <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
count := 0
|
|
||||||
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
|
|
||||||
word := strings.ToLower(flat[location[0]:location[1]])
|
|
||||||
if compactionStopWords[word] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
if count > limit {
|
|
||||||
end := location[0]
|
|
||||||
return strings.TrimSpace(flat[:end]) + "...(truncated)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flat
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipBytes(text string, limit int) string {
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if limit <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if len(text) <= limit {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
if limit <= 3 {
|
|
||||||
end := limit
|
|
||||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
return text[:end]
|
|
||||||
}
|
|
||||||
end := limit - 3
|
|
||||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(text[:end]) + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipHeadTailBytes(text string, limit int) string {
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if len(text) <= limit {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
marker := "\n...[middle omitted]...\n"
|
|
||||||
if limit <= len(marker) {
|
|
||||||
return clipBytes(text, limit)
|
|
||||||
}
|
|
||||||
available := limit - len(marker)
|
|
||||||
head := clipPrefixAtBoundary(text, available/2)
|
|
||||||
tail := clipSuffixAtBoundary(text, available-available/2)
|
|
||||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipPrefixAtBoundary(text string, limit int) string {
|
|
||||||
if limit <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if len(text) <= limit {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
end := limit
|
|
||||||
for end > 0 && text[end]&0xc0 == 0x80 {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
|
|
||||||
end = boundary
|
|
||||||
}
|
|
||||||
return text[:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipSuffixAtBoundary(text string, limit int) string {
|
|
||||||
if limit <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if len(text) <= limit {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
start := len(text) - limit
|
|
||||||
for start < len(text) && text[start]&0xc0 == 0x80 {
|
|
||||||
start++
|
|
||||||
}
|
|
||||||
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
|
|
||||||
start += boundary + 2
|
|
||||||
}
|
|
||||||
return text[start:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func askUserQuestionsDetail(arguments map[string]any) string {
|
|
||||||
raw, ok := arguments["questions"].([]any)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
questions := make([]string, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
object, ok := item.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
|
|
||||||
questions = append(questions, strings.TrimSpace(question))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(questions, " | ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func patchPaths(patch string) []string {
|
|
||||||
var paths []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, line := range strings.Split(patch, "\n") {
|
|
||||||
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
|
|
||||||
if !strings.HasPrefix(line, prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
||||||
if path != "" && !seen[path] {
|
|
||||||
seen[path] = true
|
|
||||||
paths = append(paths, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
var compactionStopWords = map[string]bool{
|
|
||||||
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
|
|
||||||
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
|
|
||||||
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
|
|
||||||
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
|
|
||||||
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
|
|
||||||
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
|
|
||||||
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
|
|
||||||
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
|
|
||||||
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
|
|
||||||
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
|
|
||||||
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
|
|
||||||
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
|
|
||||||
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
|
|
||||||
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
|
|
||||||
"when": true, "where": true, "how": true, "just": true, "also": true,
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
|
|
||||||
for _, limit := range []int{0, 1, 2, 3} {
|
|
||||||
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
|
|
||||||
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
|
|
||||||
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
|
|
||||||
for _, limit := range []int{-1, 0} {
|
|
||||||
if got := clip("世界", limit); got != "" {
|
|
||||||
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
|
|
||||||
largeBody := strings.Repeat("recoverable source line\n", 1000)
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
|
|
||||||
}
|
|
||||||
|
|
||||||
projection := projectCompactionInput(messages)
|
|
||||||
projected := projection.messages
|
|
||||||
if len(projected) != 1 {
|
|
||||||
t.Fatalf("projection = %#v, want one compact message", projected)
|
|
||||||
}
|
|
||||||
content := projected[0].Content
|
|
||||||
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
|
|
||||||
if !strings.Contains(content, want) {
|
|
||||||
t.Fatalf("projection missing %q: %q", want, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
|
|
||||||
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
|
|
||||||
}
|
|
||||||
if estimateTokens(projected)*20 >= estimateTokens(messages) {
|
|
||||||
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
projected := projectCompactionInput(messages).messages
|
|
||||||
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
|
|
||||||
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
||||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
|
||||||
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
|
|
||||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
|
||||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
|
||||||
}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
content := projectCompactionInput(messages).messages[0].Content
|
|
||||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
|
||||||
if !strings.Contains(content, want) {
|
|
||||||
t.Fatalf("projection missing %q: %q", want, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
|
|
||||||
calls := make([]zeroruntime.ToolCall, 12)
|
|
||||||
for index := range calls {
|
|
||||||
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
|
|
||||||
}
|
|
||||||
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
|
|
||||||
content := projected[0].Content
|
|
||||||
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
|
|
||||||
t.Fatalf("tool-call tail was not bounded: %q", content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
|
|
||||||
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
|
|
||||||
for index := range 500 {
|
|
||||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
|
|
||||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
|
|
||||||
}
|
|
||||||
|
|
||||||
projection := projectCompactionInput(messages)
|
|
||||||
content := projection.messages[0].Content
|
|
||||||
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
|
|
||||||
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
|
|
||||||
t.Fatalf("oversized brief was not head/tail capped: %q", content)
|
|
||||||
}
|
|
||||||
if !projection.truncated {
|
|
||||||
t.Fatal("projection did not structurally report truncation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
|
|
||||||
projection := projectCompactionInput([]zeroruntime.Message{{
|
|
||||||
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
|
|
||||||
}})
|
|
||||||
if projection.truncated {
|
|
||||||
t.Fatal("literal user text produced a false truncation signal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,15 +20,11 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
|||||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||||
}},
|
}},
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
||||||
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
|
|
||||||
}},
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
edits := recentEdits(messages)
|
edits := recentEdits(messages)
|
||||||
if len(edits) != 3 {
|
if len(edits) != 2 {
|
||||||
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
|
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") {
|
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||||
@@ -36,9 +32,6 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
|||||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||||
}
|
}
|
||||||
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
|
|
||||||
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// After compaction elides the editing turns, the preserved-state block still
|
// After compaction elides the editing turns, the preserved-state block still
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/trace"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,15 +58,12 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
|||||||
if last.Content != "most recent question" {
|
if last.Content != "most recent question" {
|
||||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||||
}
|
}
|
||||||
// The summarizer receives a compact semantic projection of the middle rather
|
// The summarized middle excludes system and preserved suffix.
|
||||||
// than reconstructible raw tool output.
|
if len(captured) != 3 {
|
||||||
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
|
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
||||||
t.Fatalf("expected one projected summary input, got %#v", captured)
|
|
||||||
}
|
}
|
||||||
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
|
if captured[0].Content != "first question" {
|
||||||
if !strings.Contains(captured[0].Content, want) {
|
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
||||||
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Compaction must shrink the conversation.
|
// Compaction must shrink the conversation.
|
||||||
if estimateTokens(result) >= estimateTokens(messages) {
|
if estimateTokens(result) >= estimateTokens(messages) {
|
||||||
@@ -275,27 +271,6 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
|||||||
if estimateToolDefTokens(one) <= 0 {
|
if estimateToolDefTokens(one) <= 0 {
|
||||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
||||||
}
|
}
|
||||||
typed := append([]zeroruntime.ToolDefinition(nil), one...)
|
|
||||||
typed[0].Type = zeroruntime.ToolDefinitionFreeform
|
|
||||||
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
|
|
||||||
t.Fatal("a non-empty tool type must increase the estimated request cost")
|
|
||||||
}
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
format zeroruntime.ToolDefinitionFormat
|
|
||||||
}{
|
|
||||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
|
||||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
|
||||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
|
|
||||||
} {
|
|
||||||
t.Run("format "+test.name, func(t *testing.T) {
|
|
||||||
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
|
|
||||||
formatted[0].Format = &test.format
|
|
||||||
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
|
|
||||||
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||||
Name: "write_file",
|
Name: "write_file",
|
||||||
Description: "Write contents to a file in the workspace.",
|
Description: "Write contents to a file in the workspace.",
|
||||||
@@ -361,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registry := tools.NewRegistry()
|
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{
|
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
@@ -389,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
registry := tools.NewRegistry()
|
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{
|
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
@@ -404,43 +379,6 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
|
|
||||||
body := strings.Repeat("same source line\n", 1000)
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
|
||||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
|
||||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
|
||||||
}
|
|
||||||
pruned, reclaimed := pruneSupersededReadResults(messages)
|
|
||||||
if reclaimed <= 0 {
|
|
||||||
t.Fatal("test setup did not reclaim superseded read output")
|
|
||||||
}
|
|
||||||
prunedSize := estimateTokens(pruned)
|
|
||||||
if estimateTokens(messages) <= prunedSize {
|
|
||||||
t.Fatal("test setup does not cross the compaction threshold")
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := &summarizeRecordingProvider{}
|
|
||||||
state := &compactionState{
|
|
||||||
enabled: true,
|
|
||||||
threshold: prunedSize,
|
|
||||||
preserveLast: 2,
|
|
||||||
}
|
|
||||||
got := state.maybeCompact(context.Background(), provider, messages, nil)
|
|
||||||
|
|
||||||
if provider.summarizeCalls != 0 {
|
|
||||||
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
|
|
||||||
}
|
|
||||||
if state.lowWaterMark != prunedSize {
|
|
||||||
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
|
|
||||||
t.Fatalf("unexpected pruned messages: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reactiveProvider builds up history with a tool call on turn 1, then errors
|
// reactiveProvider builds up history with a tool call on turn 1, then errors
|
||||||
// with a context-limit message on turn 2 (once the history is large enough that
|
// with a context-limit message on turn 2 (once the history is large enough that
|
||||||
// compaction can shrink it), then succeeds on the same-turn retry. Summary
|
// compaction can shrink it), then succeeds on the same-turn retry. Summary
|
||||||
@@ -450,7 +388,6 @@ type reactiveProvider struct {
|
|||||||
summarizeCalls int
|
summarizeCalls int
|
||||||
turnRequests int
|
turnRequests int
|
||||||
failedOnce bool
|
failedOnce bool
|
||||||
connectError bool
|
|
||||||
bigText string
|
bigText string
|
||||||
finalText string
|
finalText string
|
||||||
}
|
}
|
||||||
@@ -472,9 +409,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
|||||||
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
||||||
case provider.turnRequests == 2 && !provider.failedOnce:
|
case provider.turnRequests == 2 && !provider.failedOnce:
|
||||||
provider.failedOnce = true
|
provider.failedOnce = true
|
||||||
if provider.connectError {
|
|
||||||
return nil, errors.New("context_length_exceeded")
|
|
||||||
}
|
|
||||||
return streamEvents([]zeroruntime.StreamEvent{
|
return streamEvents([]zeroruntime.StreamEvent{
|
||||||
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
||||||
}), nil
|
}), nil
|
||||||
@@ -483,39 +417,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
|
|
||||||
provider := &reactiveProvider{
|
|
||||||
connectError: true,
|
|
||||||
bigText: strings.Repeat("b", 6000),
|
|
||||||
finalText: "recovered",
|
|
||||||
}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
|
||||||
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
|
|
||||||
var contextPlans []ContextBreakdown
|
|
||||||
|
|
||||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
|
||||||
Registry: registry,
|
|
||||||
PermissionMode: PermissionModeUnsafe,
|
|
||||||
ContextWindow: 10_000_000,
|
|
||||||
CompactionPreserveLast: 2,
|
|
||||||
Trace: recorder,
|
|
||||||
OnContext: func(breakdown ContextBreakdown) {
|
|
||||||
contextPlans = append(contextPlans, breakdown)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil || result.FinalAnswer != "recovered" {
|
|
||||||
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
|
|
||||||
}
|
|
||||||
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
|
||||||
t.Fatalf("replacement request context not reported: %#v", contextPlans)
|
|
||||||
}
|
|
||||||
gotTrace := recorder.Finish()
|
|
||||||
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
|
||||||
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunReactiveCompactionRecovers(t *testing.T) {
|
func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||||
provider := &reactiveProvider{
|
provider := &reactiveProvider{
|
||||||
bigText: strings.Repeat("b", 6000),
|
bigText: strings.Repeat("b", 6000),
|
||||||
@@ -526,9 +427,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
|||||||
// the compaction closure. read_file also returns a sizeable result that
|
// the compaction closure. read_file also returns a sizeable result that
|
||||||
// bloats the history.
|
// bloats the history.
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||||
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
|
|
||||||
var contextPlans []ContextBreakdown
|
|
||||||
|
|
||||||
// ContextWindow large enough that proactive compaction never triggers, so
|
// ContextWindow large enough that proactive compaction never triggers, so
|
||||||
// only the reactive path can save the run.
|
// only the reactive path can save the run.
|
||||||
@@ -537,10 +436,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
|||||||
PermissionMode: PermissionModeUnsafe,
|
PermissionMode: PermissionModeUnsafe,
|
||||||
ContextWindow: 10_000_000,
|
ContextWindow: 10_000_000,
|
||||||
CompactionPreserveLast: 2,
|
CompactionPreserveLast: 2,
|
||||||
Trace: recorder,
|
|
||||||
OnContext: func(breakdown ContextBreakdown) {
|
|
||||||
contextPlans = append(contextPlans, breakdown)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
||||||
@@ -551,25 +446,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
|||||||
if provider.summarizeCalls == 0 {
|
if provider.summarizeCalls == 0 {
|
||||||
t.Fatal("expected reactive compaction to invoke the summarizer")
|
t.Fatal("expected reactive compaction to invoke the summarizer")
|
||||||
}
|
}
|
||||||
if len(contextPlans) != 3 {
|
|
||||||
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
|
|
||||||
}
|
|
||||||
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
|
||||||
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
|
|
||||||
}
|
|
||||||
gotTrace := recorder.Finish()
|
|
||||||
if len(gotTrace.PrefixHashes) != 4 {
|
|
||||||
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
|
|
||||||
}
|
|
||||||
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
|
|
||||||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
|
|
||||||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
|
|
||||||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
|
||||||
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
|
|
||||||
}
|
|
||||||
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
|
|
||||||
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
|
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
|
||||||
@@ -615,7 +491,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
|||||||
finalText: "recovered",
|
finalText: "recovered",
|
||||||
}
|
}
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||||
|
|
||||||
var deltas []string
|
var deltas []string
|
||||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||||
@@ -705,7 +581,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(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{{
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||||
}}}
|
}}}
|
||||||
@@ -752,7 +628,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRecoverDisabledIsNoop(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"}}
|
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||||
called := false
|
called := false
|
||||||
// recover must not invoke the provider/summarize when disabled, even on a
|
// recover must not invoke the provider/summarize when disabled, even on a
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
|||||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
|
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
|
||||||
// A direct admission is strong local evidence and takes precedence over all
|
// A direct admission is strong local evidence and takes precedence over all
|
||||||
// weaker signals, avoiding both continuation nudges and semantic checks.
|
// weaker signals, avoiding both continuation nudges and semantic checks.
|
||||||
if reason := selfReportedIncompletion(text); reason != "" {
|
if reason := selfReportedIncompletion(text); reason != "" {
|
||||||
@@ -50,7 +50,7 @@ func (policy *completionPolicy) evaluate(text string, context completionContext)
|
|||||||
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
||||||
// to continue, but only a persisted cue is ultimately incomplete.
|
// to continue, but only a persisted cue is ultimately incomplete.
|
||||||
cue := endsWithContinuationCue(text)
|
cue := endsWithContinuationCue(text)
|
||||||
if cue || context.PlanPending {
|
if cue || planPending {
|
||||||
if policy.continueNudges < maxContinueNudges {
|
if policy.continueNudges < maxContinueNudges {
|
||||||
policy.continueNudges++
|
policy.continueNudges++
|
||||||
reason := "your message ended mid-step"
|
reason := "your message ended mid-step"
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||||
policy := newCompletionPolicy(false)
|
policy := newCompletionPolicy(false)
|
||||||
|
|
||||||
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
complete := policy.evaluate("Done. All required checks pass.", false)
|
||||||
if complete.Decision != CompletionComplete {
|
if complete.Decision != CompletionComplete {
|
||||||
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
|
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
|
||||||
if incomplete.Decision != CompletionIncomplete {
|
if incomplete.Decision != CompletionIncomplete {
|
||||||
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
||||||
}
|
}
|
||||||
@@ -22,13 +19,13 @@ func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T)
|
|||||||
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||||
policy := newCompletionPolicy(false)
|
policy := newCompletionPolicy(false)
|
||||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||||
if got.Decision != CompletionIncomplete {
|
if got.Decision != CompletionIncomplete {
|
||||||
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
||||||
}
|
}
|
||||||
@@ -37,13 +34,13 @@ func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
|||||||
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||||
policy := newCompletionPolicy(false)
|
policy := newCompletionPolicy(false)
|
||||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
got := policy.evaluate("All set.", true)
|
||||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
got := policy.evaluate("All set.", true)
|
||||||
if got.Decision != CompletionComplete {
|
if got.Decision != CompletionComplete {
|
||||||
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
||||||
}
|
}
|
||||||
@@ -52,12 +49,12 @@ func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
|||||||
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||||
policy := newCompletionPolicy(true)
|
policy := newCompletionPolicy(true)
|
||||||
|
|
||||||
first := policy.evaluate("Implemented and tested.", completionContext{})
|
first := policy.evaluate("Implemented and tested.", false)
|
||||||
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
||||||
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
||||||
}
|
}
|
||||||
|
|
||||||
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
|
second := policy.evaluate("PASS. The result meets the task criterion.", false)
|
||||||
if second.Decision != CompletionComplete {
|
if second.Decision != CompletionComplete {
|
||||||
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
||||||
}
|
}
|
||||||
@@ -65,11 +62,3 @@ func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
|||||||
t.Fatal("semantic check was requested more than once")
|
t.Fatal("semantic check was requested more than once")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
|
||||||
objective := "make the requested behavior observable"
|
|
||||||
nudge := acceptanceVerificationNudge(objective)
|
|
||||||
if !strings.Contains(nudge, "Task objective: "+objective) {
|
|
||||||
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,24 +23,6 @@ type ContextBreakdown struct {
|
|||||||
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
||||||
ContextWindow int // model context window; 0 when unknown
|
ContextWindow int // model context window; 0 when unknown
|
||||||
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
|
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
|
||||||
// Blocks explain why each model-visible category is present without exposing
|
|
||||||
// its content. The planner currently preserves all three categories.
|
|
||||||
Blocks []ContextBlock
|
|
||||||
// CompletePrefixHash identifies the provider-visible system+tool prefix.
|
|
||||||
CompletePrefixHash string
|
|
||||||
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
|
|
||||||
// separated list of changed prefix categories.
|
|
||||||
PrefixInvalidationReason string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextBlock is content-free evidence for one model-visible context category.
|
|
||||||
type ContextBlock struct {
|
|
||||||
Kind string
|
|
||||||
Tokens int
|
|
||||||
CacheClass string
|
|
||||||
Authority string
|
|
||||||
Recoverable bool
|
|
||||||
Reason string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MeasureContext estimates the per-category token footprint of a request: the
|
// MeasureContext estimates the per-category token footprint of a request: the
|
||||||
@@ -59,24 +41,6 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
|||||||
ContextWindow: contextWindow,
|
ContextWindow: contextWindow,
|
||||||
}
|
}
|
||||||
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
|
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
|
||||||
if breakdown.SystemTokens > 0 {
|
|
||||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
|
||||||
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
|
|
||||||
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if breakdown.ToolTokens > 0 {
|
|
||||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
|
||||||
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
|
|
||||||
Authority: "capability", Reason: "currently permitted and selected tool definitions",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if breakdown.MessageTokens > 0 {
|
|
||||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
|
||||||
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
|
|
||||||
Authority: "transcript", Reason: "valid provider replay history and current task context",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if contextWindow > 0 {
|
if contextWindow > 0 {
|
||||||
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
|
|||||||
if breakdown.UsedFraction != wantFraction {
|
if breakdown.UsedFraction != wantFraction {
|
||||||
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
|
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
|
||||||
}
|
}
|
||||||
if len(breakdown.Blocks) != 3 {
|
|
||||||
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
|
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
type contextPlannerConfig struct {
|
|
||||||
contextWindow int
|
|
||||||
promptCacheKey string
|
|
||||||
serviceTier string
|
|
||||||
promptParts systemPromptParts
|
|
||||||
}
|
|
||||||
|
|
||||||
// contextPlanner is the single seam for constructing model-visible requests.
|
|
||||||
// Planning is deterministic and does not retrieve content, execute tools, or
|
|
||||||
// change permissions. The initial implementation deliberately preserves every
|
|
||||||
// message and tool definition while making composition and cache drift
|
|
||||||
// inspectable; later selection policies must continue to cross this seam.
|
|
||||||
type contextPlanner struct {
|
|
||||||
config contextPlannerConfig
|
|
||||||
previousPrefix prefixFingerprint
|
|
||||||
hasPrevious bool
|
|
||||||
toolSnapshotKey string
|
|
||||||
toolSnapshot []zeroruntime.ToolDefinition
|
|
||||||
}
|
|
||||||
|
|
||||||
type contextPlan struct {
|
|
||||||
Request zeroruntime.CompletionRequest
|
|
||||||
Breakdown ContextBreakdown
|
|
||||||
PrefixFingerprint prefixFingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
|
|
||||||
return &contextPlanner{config: config}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan returns a provider request snapshot plus content-free accounting.
|
|
||||||
// It intentionally performs no relevance filtering: preserving current model
|
|
||||||
// capability is the baseline contract for future planner policies.
|
|
||||||
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
|
|
||||||
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// planWithPromptParts plans a request whose stable system sections differ from
|
|
||||||
// the planner's configured main-run prompt, such as a compaction summary call.
|
|
||||||
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
|
||||||
return planner.plan(messages, toolDefs, reasoningEffort, parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
|
||||||
requestMessages := copyMessages(messages)
|
|
||||||
requestSystemPrompt := leadingSystemContent(requestMessages)
|
|
||||||
if parts.prompt != requestSystemPrompt {
|
|
||||||
// Component boundaries from a different request would make the detailed
|
|
||||||
// invalidation reason misleading. Retain the exact request prefix and
|
|
||||||
// classify it conservatively as an unstructured system prompt instead.
|
|
||||||
parts = systemPromptParts{prompt: requestSystemPrompt}
|
|
||||||
}
|
|
||||||
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
|
|
||||||
request := zeroruntime.CompletionRequest{
|
|
||||||
Messages: requestMessages,
|
|
||||||
Tools: planner.snapshotTools(toolDefs, fingerprint),
|
|
||||||
ReasoningEffort: reasoningEffort,
|
|
||||||
ServiceTier: planner.config.serviceTier,
|
|
||||||
PromptCacheKey: planner.config.promptCacheKey,
|
|
||||||
}
|
|
||||||
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
|
|
||||||
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
|
|
||||||
var previous *prefixFingerprint
|
|
||||||
if planner.hasPrevious {
|
|
||||||
previous = &planner.previousPrefix
|
|
||||||
}
|
|
||||||
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
|
|
||||||
planner.previousPrefix = fingerprint
|
|
||||||
planner.hasPrevious = true
|
|
||||||
return contextPlan{
|
|
||||||
Request: request,
|
|
||||||
Breakdown: breakdown,
|
|
||||||
PrefixFingerprint: fingerprint,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshotTools freezes provider-visible tool definitions when their semantic
|
|
||||||
// fingerprint changes. Stable turns reuse the frozen schemas instead of
|
|
||||||
// recursively cloning every map and slice for every request.
|
|
||||||
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
|
|
||||||
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
|
|
||||||
if planner.toolSnapshotKey != key {
|
|
||||||
planner.toolSnapshot = copyToolDefinitions(toolDefs)
|
|
||||||
planner.toolSnapshotKey = key
|
|
||||||
}
|
|
||||||
return planner.toolSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
|
|
||||||
if toolDefs == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
|
|
||||||
for index, definition := range toolDefs {
|
|
||||||
copied[index] = definition
|
|
||||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
|
||||||
if definition.Format != nil {
|
|
||||||
format := *definition.Format
|
|
||||||
copied[index].Format = &format
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return copied
|
|
||||||
}
|
|
||||||
|
|
||||||
func copySchemaMap(schema map[string]any) map[string]any {
|
|
||||||
if schema == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
copied := make(map[string]any, len(schema))
|
|
||||||
for key, value := range schema {
|
|
||||||
copied[key] = copySchemaValue(value)
|
|
||||||
}
|
|
||||||
return copied
|
|
||||||
}
|
|
||||||
|
|
||||||
func copySchemaValue(value any) any {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
return copySchemaMap(typed)
|
|
||||||
case []any:
|
|
||||||
copied := make([]any, len(typed))
|
|
||||||
for index, item := range typed {
|
|
||||||
copied[index] = copySchemaValue(item)
|
|
||||||
}
|
|
||||||
return copied
|
|
||||||
case []string:
|
|
||||||
return append([]string(nil), typed...)
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func leadingSystemContent(messages []zeroruntime.Message) string {
|
|
||||||
contents := make([]string, 0, 1)
|
|
||||||
for _, message := range messages {
|
|
||||||
if message.Role != zeroruntime.MessageRoleSystem {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
contents = append(contents, message.Content)
|
|
||||||
}
|
|
||||||
return strings.Join(contents, "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
|
|
||||||
if previous == nil {
|
|
||||||
return "initial"
|
|
||||||
}
|
|
||||||
if previous.CompletePrefixHash == current.CompletePrefixHash {
|
|
||||||
return "unchanged"
|
|
||||||
}
|
|
||||||
reasons := make([]string, 0, 3)
|
|
||||||
if previous.SystemPromptHash != current.SystemPromptHash {
|
|
||||||
parts := make([]string, 0, 4)
|
|
||||||
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
|
|
||||||
parts = append(parts, "base_instructions")
|
|
||||||
}
|
|
||||||
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
|
|
||||||
parts = append(parts, "confirmation_policy")
|
|
||||||
}
|
|
||||||
if previous.ProjectContextHash != current.ProjectContextHash {
|
|
||||||
parts = append(parts, "project_context")
|
|
||||||
}
|
|
||||||
if previous.SkillsHash != current.SkillsHash {
|
|
||||||
parts = append(parts, "skills")
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
|
||||||
parts = append(parts, "system_prompt")
|
|
||||||
}
|
|
||||||
reasons = append(reasons, parts...)
|
|
||||||
}
|
|
||||||
if previous.ToolsHash != current.ToolsHash {
|
|
||||||
reasons = append(reasons, "tools")
|
|
||||||
}
|
|
||||||
if previous.SchemaHash != current.SchemaHash {
|
|
||||||
reasons = append(reasons, "schema")
|
|
||||||
}
|
|
||||||
if len(reasons) == 0 {
|
|
||||||
return "prefix_changed"
|
|
||||||
}
|
|
||||||
return strings.Join(reasons, ",")
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
|
|
||||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
|
|
||||||
}
|
|
||||||
toolDefs := []zeroruntime.ToolDefinition{{
|
|
||||||
Name: "read_file", Description: "Read a file",
|
|
||||||
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
|
|
||||||
Parameters: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"required": []string{"path"},
|
|
||||||
"properties": map[string]any{"path": map[string]any{"type": "string"}},
|
|
||||||
"anyOf": []any{map[string]any{"type": "object"}},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
planner := newContextPlanner(contextPlannerConfig{
|
|
||||||
contextWindow: 128_000,
|
|
||||||
promptCacheKey: "session-1",
|
|
||||||
serviceTier: "priority",
|
|
||||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
|
||||||
})
|
|
||||||
|
|
||||||
plan := planner.Plan(messages, toolDefs, "medium")
|
|
||||||
want := zeroruntime.CompletionRequest{
|
|
||||||
Messages: copyMessages(messages),
|
|
||||||
Tools: toolDefs,
|
|
||||||
ReasoningEffort: "medium",
|
|
||||||
ServiceTier: "priority",
|
|
||||||
PromptCacheKey: "session-1",
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(plan.Request, want) {
|
|
||||||
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
|
|
||||||
}
|
|
||||||
stable := planner.Plan(messages, toolDefs, "medium")
|
|
||||||
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
|
|
||||||
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plan owns its request snapshot; later caller mutation cannot change its
|
|
||||||
// messages or its recursively nested tool schema.
|
|
||||||
messages[1].Content = "changed"
|
|
||||||
messages[1].Images[0].Data[0] = 9
|
|
||||||
toolDefs[0].Name = "changed_tool"
|
|
||||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
|
||||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
|
||||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
|
||||||
toolDefs[0].Format.Definition = "changed"
|
|
||||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
|
||||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
|
||||||
}
|
|
||||||
if plan.Request.Tools[0].Name != "read_file" ||
|
|
||||||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
|
|
||||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
|
||||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
|
||||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
|
||||||
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
next := planner.Plan(messages, toolDefs, "medium")
|
|
||||||
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
|
|
||||||
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
|
|
||||||
}
|
|
||||||
if plan.Request.Tools[0].Name != "read_file" {
|
|
||||||
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
|
|
||||||
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
|
|
||||||
plan := planner.Plan([]zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
|
|
||||||
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
|
|
||||||
|
|
||||||
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
|
|
||||||
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
|
|
||||||
}
|
|
||||||
if len(plan.Breakdown.Blocks) != 3 {
|
|
||||||
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
|
|
||||||
}
|
|
||||||
wantKinds := []string{"system", "tools", "conversation"}
|
|
||||||
total := 0
|
|
||||||
for index, block := range plan.Breakdown.Blocks {
|
|
||||||
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
|
|
||||||
t.Fatalf("block %d = %#v", index, block)
|
|
||||||
}
|
|
||||||
total += block.Tokens
|
|
||||||
}
|
|
||||||
if total != plan.Breakdown.TotalTokens {
|
|
||||||
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
|
|
||||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
|
||||||
prompt: "configured system", baseInstructions: "configured system",
|
|
||||||
}})
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
|
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "task"},
|
|
||||||
}
|
|
||||||
|
|
||||||
plan := planner.Plan(messages, nil, "")
|
|
||||||
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
|
|
||||||
systemPromptParts{prompt: "request-specific system"}, nil,
|
|
||||||
))
|
|
||||||
if plan.PrefixFingerprint != want {
|
|
||||||
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
|
|
||||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
|
||||||
prompt: "system", baseInstructions: "base", projectContext: "project",
|
|
||||||
}})
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
|
|
||||||
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
|
|
||||||
|
|
||||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
|
|
||||||
t.Fatalf("first reason = %q, want initial", got)
|
|
||||||
}
|
|
||||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
|
|
||||||
t.Fatalf("stable reason = %q, want unchanged", got)
|
|
||||||
}
|
|
||||||
tools[0].Description = "Read an exact file"
|
|
||||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
|
|
||||||
t.Fatalf("tool reason = %q, want tools", got)
|
|
||||||
}
|
|
||||||
tools[0].Parameters["required"] = []any{"path"}
|
|
||||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
|
|
||||||
t.Fatalf("schema reason = %q, want schema", got)
|
|
||||||
}
|
|
||||||
planner.config.promptParts.prompt = "system with changed project"
|
|
||||||
planner.config.promptParts.projectContext = "changed project"
|
|
||||||
messages[0].Content = "system with changed project"
|
|
||||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
|
|
||||||
t.Fatalf("project reason = %q, want project_context", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
|
|
||||||
baseline := prefixFingerprint{
|
|
||||||
SystemPromptHash: "system",
|
|
||||||
BaseInstructionsHash: "base",
|
|
||||||
ConfirmationPolicyHash: "policy",
|
|
||||||
ProjectContextHash: "project",
|
|
||||||
SkillsHash: "skills",
|
|
||||||
ToolsHash: "tools",
|
|
||||||
SchemaHash: "schema",
|
|
||||||
CompletePrefixHash: "complete",
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(*prefixFingerprint)
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "base instructions", mutate: func(value *prefixFingerprint) {
|
|
||||||
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
|
|
||||||
}, want: "base_instructions"},
|
|
||||||
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
|
|
||||||
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
|
|
||||||
}, want: "confirmation_policy"},
|
|
||||||
{name: "project context", mutate: func(value *prefixFingerprint) {
|
|
||||||
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
|
|
||||||
}, want: "project_context"},
|
|
||||||
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
|
|
||||||
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
|
|
||||||
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
current := baseline
|
|
||||||
test.mutate(¤t)
|
|
||||||
current.CompletePrefixHash = "changed"
|
|
||||||
if got := explainPrefixChange(&baseline, current); got != test.want {
|
|
||||||
t.Fatalf("reason = %q, want %q", got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
|
|||||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||||
registry.Register(fakeToolSearchTool{})
|
registry.Register(fakeToolSearchTool{})
|
||||||
|
|
||||||
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
|||||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||||
|
|
||||||
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
|||||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||||
}
|
}
|
||||||
if !strings.Contains(search.Description, "mcp__srv__alpha") {
|
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||||
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
|
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||||
}
|
}
|
||||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
|
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||||
@@ -368,8 +368,9 @@ func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
|
|||||||
if !exposedNames["tool_search"] {
|
if !exposedNames["tool_search"] {
|
||||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
|
// No hidden tools means no dynamic discovery text is needed.
|
||||||
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
|
if reminder != "" {
|
||||||
|
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,98 +798,3 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
|
|||||||
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
|
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeDeferredMutatorTool is a deferred-eligible tool with mutating Safety
|
|
||||||
// (SideEffectWrite), standing in for a real write/mutator MCP tool that would
|
|
||||||
// be hidden behind tool_search once deferral activates.
|
|
||||||
type fakeDeferredMutatorTool struct{ name string }
|
|
||||||
|
|
||||||
func (t fakeDeferredMutatorTool) Name() string { return t.name }
|
|
||||||
func (t fakeDeferredMutatorTool) Description() string { return "mutates the workspace, deferred" }
|
|
||||||
func (t fakeDeferredMutatorTool) Parameters() tools.Schema {
|
|
||||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
|
||||||
}
|
|
||||||
func (t fakeDeferredMutatorTool) Safety() tools.Safety {
|
|
||||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt, Reason: "mutates"}
|
|
||||||
}
|
|
||||||
func (t fakeDeferredMutatorTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
|
||||||
return tools.Result{Status: tools.StatusOK, Output: "mutated"}
|
|
||||||
}
|
|
||||||
func (t fakeDeferredMutatorTool) Deferred() bool { return true }
|
|
||||||
|
|
||||||
// TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema guards the concern
|
|
||||||
// raised in PR #642 review (jatmn, P2): that tool_search filters deferred
|
|
||||||
// candidates only by EnabledTools/DisabledTools, not by plan-mode visibility,
|
|
||||||
// so a plan-mode model could call `tool_search select:<deferred write tool>`
|
|
||||||
// and receive that tool's full schema even though a direct call to it is
|
|
||||||
// correctly denied the following turn.
|
|
||||||
//
|
|
||||||
// tool_search's own Safety is SideEffectNone, and ToolAdvertisedForPermissionMode (the
|
|
||||||
// same gate executeToolCall uses to deny a direct call) requires
|
|
||||||
// SideEffect==Read to advertise a tool in plan mode. That means tool_search
|
|
||||||
// itself is never advertised, never activates deferral (loaderUsable in
|
|
||||||
// partitionToolsCached requires ToolAdvertised(loader, permissionMode)), and
|
|
||||||
// is denied at dispatch like any other hidden tool if a stale/adversarial
|
|
||||||
// call reaches it anyway — so a deferred mutator's schema can never reach the
|
|
||||||
// model through tool_search while in plan mode. This test pins that
|
|
||||||
// end-to-end: tool_search is absent from the advertised tool list, and a
|
|
||||||
// forced call to it is denied before rendering any tool schema.
|
|
||||||
func TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
|
||||||
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate"})
|
|
||||||
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate2"})
|
|
||||||
registry.Register(tools.NewToolSearchTool(registry))
|
|
||||||
|
|
||||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
||||||
{ // turn 1: force a call to tool_search even though it should not be advertised.
|
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: tools.ToolSearchToolName},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"query":"select:mcp__srv__mutate"}`},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
{ // turn 2: final answer.
|
|
||||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
result, err := Run(context.Background(), "plan", provider, Options{
|
|
||||||
Registry: registry,
|
|
||||||
PermissionMode: PermissionModePlan,
|
|
||||||
DeferThreshold: 2, // 2 deferred mutators registered => eligible for deferral.
|
|
||||||
MaxTurns: 2,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tool_search (and the deferred mutators) must not be advertised in plan
|
|
||||||
// mode's turn 1 tool list at all.
|
|
||||||
for _, def := range provider.requests[0].Tools {
|
|
||||||
if def.Name == tools.ToolSearchToolName {
|
|
||||||
t.Fatalf("plan mode must not advertise tool_search, got %#v", provider.requests[0].Tools)
|
|
||||||
}
|
|
||||||
if def.Name == "mcp__srv__mutate" || def.Name == "mcp__srv__mutate2" {
|
|
||||||
t.Fatalf("plan mode must not advertise a deferred mutator, got %#v", provider.requests[0].Tools)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The forced call must be denied outright, never a loaded-schema result:
|
|
||||||
// the tool result must not mention the mutator's name or carry a
|
|
||||||
// load_tools signal.
|
|
||||||
var toolMessage string
|
|
||||||
for _, message := range result.Messages {
|
|
||||||
if message.Role == zeroruntime.MessageRoleTool {
|
|
||||||
toolMessage = message.Content
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.Contains(toolMessage, "not available in plan mode") {
|
|
||||||
t.Fatalf("expected tool_search call denied in plan mode, got %q", toolMessage)
|
|
||||||
}
|
|
||||||
if strings.Contains(toolMessage, "mcp__srv__mutate") {
|
|
||||||
t.Fatalf("denial must not leak the deferred mutator's name/schema, got %q", toolMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
|
|
||||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||||
DisabledTools: []string{"read_file"},
|
DisabledTools: []string{"read_file"},
|
||||||
|
|||||||
@@ -144,10 +144,9 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
|||||||
var switchedTo string
|
var switchedTo string
|
||||||
switchCount := 0
|
switchCount := 0
|
||||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Model: "claude-sonnet-4.5",
|
Model: "claude-sonnet-4.5",
|
||||||
ServiceTier: "priority",
|
MaxTurns: 4,
|
||||||
MaxTurns: 4,
|
|
||||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||||
switchCount++
|
switchCount++
|
||||||
switchedTo = modelID
|
switchedTo = modelID
|
||||||
@@ -168,15 +167,9 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
|||||||
if len(firstProvider.requests) != 1 {
|
if len(firstProvider.requests) != 1 {
|
||||||
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
||||||
}
|
}
|
||||||
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
|
|
||||||
t.Fatalf("initial request service tier = %q, want priority", got)
|
|
||||||
}
|
|
||||||
if len(secondProvider.requests) != 1 {
|
if len(secondProvider.requests) != 1 {
|
||||||
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
||||||
}
|
}
|
||||||
if got := secondProvider.requests[0].ServiceTier; got != "" {
|
|
||||||
t.Fatalf("post-switch request retained service tier %q", got)
|
|
||||||
}
|
|
||||||
if result.FinalAnswer != "answered on the upgraded model" {
|
if result.FinalAnswer != "answered on the upgraded model" {
|
||||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
||||||
}
|
}
|
||||||
@@ -218,10 +211,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
|||||||
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||||
|
|
||||||
result, err := Run(context.Background(), "go", provider, Options{
|
result, err := Run(context.Background(), "go", provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Model: "claude-sonnet-4.5",
|
Model: "claude-sonnet-4.5",
|
||||||
ServiceTier: "priority",
|
MaxTurns: 4,
|
||||||
MaxTurns: 4,
|
|
||||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||||
return nil, errors.New("provider build blew up")
|
return nil, errors.New("provider build blew up")
|
||||||
},
|
},
|
||||||
@@ -233,9 +225,6 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
|||||||
if len(provider.requests) != 2 {
|
if len(provider.requests) != 2 {
|
||||||
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
||||||
}
|
}
|
||||||
if got := provider.requests[1].ServiceTier; got != "priority" {
|
|
||||||
t.Fatalf("failed-switch request service tier = %q, want priority", got)
|
|
||||||
}
|
|
||||||
if result.FinalAnswer != "recovered" {
|
if result.FinalAnswer != "recovered" {
|
||||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,378 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
type customApplyPatchTool struct {
|
|
||||||
calls int
|
|
||||||
}
|
|
||||||
|
|
||||||
type fixedPathScope struct {
|
|
||||||
roots []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (scope fixedPathScope) Roots() []string {
|
|
||||||
return append([]string(nil), scope.roots...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
|
|
||||||
t.Helper()
|
|
||||||
resolved := make([]string, 0, len(roots))
|
|
||||||
for _, root := range roots {
|
|
||||||
absolute, err := filepath.Abs(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Abs(%q): %v", root, err)
|
|
||||||
}
|
|
||||||
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
|
|
||||||
absolute = physical
|
|
||||||
}
|
|
||||||
resolved = append(resolved, absolute)
|
|
||||||
}
|
|
||||||
return fixedPathScope{roots: resolved}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*customApplyPatchTool) Name() string { return "apply_patch" }
|
|
||||||
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
|
|
||||||
func (*customApplyPatchTool) Parameters() tools.Schema {
|
|
||||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
|
|
||||||
}
|
|
||||||
func (*customApplyPatchTool) Safety() tools.Safety {
|
|
||||||
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
|
|
||||||
}
|
|
||||||
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
|
|
||||||
tool.calls++
|
|
||||||
return tools.Result{Status: tools.StatusOK}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
|
||||||
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
|
||||||
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1",
|
|
||||||
Name: "apply_patch",
|
|
||||||
Arguments: patch,
|
|
||||||
Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read patched file: %v", err)
|
|
||||||
}
|
|
||||||
if string(content) != "hello\n" {
|
|
||||||
t.Fatalf("patched content = %q, want hello newline", content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
extra := t.TempDir()
|
|
||||||
scope, err := sandbox.NewScope(root, []string{extra})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewScope: %v", err)
|
|
||||||
}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
|
||||||
target := filepath.Join(extra, "hello.txt")
|
|
||||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
|
|
||||||
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(target)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read extra-root patched file: %v", err)
|
|
||||||
}
|
|
||||||
if string(content) != "hello\n" {
|
|
||||||
t.Fatalf("patched content = %q, want hello newline", content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
|
|
||||||
for _, extraHasMatchingPath := range []bool{false, true} {
|
|
||||||
name := "extra target missing"
|
|
||||||
if extraHasMatchingPath {
|
|
||||||
name = "extra target exists"
|
|
||||||
}
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
extra := t.TempDir()
|
|
||||||
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
workspaceTarget := filepath.Join(workspace, "src", "a.go")
|
|
||||||
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
extraTarget := filepath.Join(extra, "src", "a.go")
|
|
||||||
if extraHasMatchingPath {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
|
||||||
extraCreated := filepath.Join(extra, "b.go")
|
|
||||||
patch := strings.Join([]string{
|
|
||||||
"*** Begin Patch",
|
|
||||||
"*** Update File: src/a.go",
|
|
||||||
"@@",
|
|
||||||
"-old",
|
|
||||||
"+new",
|
|
||||||
"*** Add File: " + extraCreated,
|
|
||||||
"+extra",
|
|
||||||
"*** End Patch",
|
|
||||||
}, "\n")
|
|
||||||
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
|
|
||||||
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
|
|
||||||
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
|
|
||||||
}
|
|
||||||
if extraHasMatchingPath {
|
|
||||||
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
|
|
||||||
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("mixed-root patch created extra file: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
useExtraRoot bool
|
|
||||||
}{
|
|
||||||
{name: "workspace"},
|
|
||||||
{name: "extra root", useExtraRoot: true},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
extra := t.TempDir()
|
|
||||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
root := workspace
|
|
||||||
if test.useExtraRoot {
|
|
||||||
root = extra
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
target := filepath.Join(root, "src", "a.go")
|
|
||||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
updatePath := target
|
|
||||||
if !test.useExtraRoot {
|
|
||||||
updatePath = filepath.Join("src", "a.go")
|
|
||||||
}
|
|
||||||
created := filepath.Join(root, "b.go")
|
|
||||||
patch := strings.Join([]string{
|
|
||||||
"*** Begin Patch",
|
|
||||||
"*** Update File: " + updatePath,
|
|
||||||
"@@",
|
|
||||||
"-old",
|
|
||||||
"+new",
|
|
||||||
"*** Add File: " + created,
|
|
||||||
"+created",
|
|
||||||
"*** End Patch",
|
|
||||||
}, "\n")
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
|
|
||||||
t.Fatalf("updated content=%q err=%v", content, err)
|
|
||||||
}
|
|
||||||
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
|
|
||||||
t.Fatalf("created content=%q err=%v", content, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
granted := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
scope := newFixedPathScope(t, root, granted)
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
|
||||||
target := filepath.Join(outside, "blocked.txt")
|
|
||||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
|
||||||
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{Cwd: root})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusError {
|
|
||||||
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("ungranted absolute patch created target: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("creating directory symlinks requires privileges on Windows")
|
|
||||||
}
|
|
||||||
root := t.TempDir()
|
|
||||||
extra := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
intermediate := filepath.Join(extra, "nested")
|
|
||||||
if err := os.Mkdir(intermediate, 0o755); err != nil {
|
|
||||||
t.Fatalf("Mkdir: %v", err)
|
|
||||||
}
|
|
||||||
scope := newFixedPathScope(t, root, extra)
|
|
||||||
tool := tools.NewScopedApplyPatchTool(root, scope)
|
|
||||||
target := filepath.Join(intermediate, "blocked.txt")
|
|
||||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
|
||||||
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Remove(intermediate); err != nil {
|
|
||||||
t.Fatalf("Remove intermediate: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Symlink(outside, intermediate); err != nil {
|
|
||||||
t.Fatalf("Symlink: %v", err)
|
|
||||||
}
|
|
||||||
result := tool.Run(context.Background(), args)
|
|
||||||
if result.Status != tools.StatusError {
|
|
||||||
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
|
|
||||||
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
|
|
||||||
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusError {
|
|
||||||
t.Fatalf("unknown freeform status = %s, want error", result.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
|
|
||||||
custom := &customApplyPatchTool{}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(custom)
|
|
||||||
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
|
|
||||||
}, PermissionModeUnsafe, Options{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
|
|
||||||
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
|
|
||||||
}
|
|
||||||
if custom.calls != 0 {
|
|
||||||
t.Fatalf("custom apply_patch executed %d times", custom.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
|
||||||
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
|
|
||||||
|
|
||||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
|
||||||
ID: "call-1",
|
|
||||||
Name: "apply_patch",
|
|
||||||
Arguments: patch,
|
|
||||||
Freeform: true,
|
|
||||||
}, PermissionModeAsk, Options{
|
|
||||||
Cwd: root,
|
|
||||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
|
||||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", err)
|
|
||||||
}
|
|
||||||
if result.Status != tools.StatusError {
|
|
||||||
t.Fatalf("denied freeform status = %s, want error", result.Status)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("denied freeform patch changed the workspace: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
|
|
||||||
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
|
|
||||||
safe := historySafeToolCalls([]ToolCall{{
|
|
||||||
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
|
|
||||||
}})
|
|
||||||
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
|
|
||||||
t.Fatalf("freeform history changed raw call: %#v", safe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
|
|
||||||
messages := appendAbortedToolResults(nil, []ToolCall{{
|
|
||||||
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
|
|
||||||
}})
|
|
||||||
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
|
|
||||||
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -33,11 +34,11 @@ const (
|
|||||||
// knows before spending more tool turns.
|
// knows before spending more tool turns.
|
||||||
toolOnlyProgressReminderAt = 6
|
toolOnlyProgressReminderAt = 6
|
||||||
|
|
||||||
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
||||||
// work plan-free while still nudging a genuinely sustained run that has not
|
// task should have called update_plan; if it hasn't, a one-time reminder is
|
||||||
// established any structured progress. Both thresholds must be met.
|
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
||||||
planReminderTurn = 7
|
// without a spurious planning nag.
|
||||||
planReminderToolThreshold = 7
|
planReminderTurn = 3
|
||||||
|
|
||||||
// planToolName is the planning tool the loop watches for by name.
|
// planToolName is the planning tool the loop watches for by name.
|
||||||
planToolName = "update_plan"
|
planToolName = "update_plan"
|
||||||
@@ -126,8 +127,9 @@ func endsWithContinuationCue(text string) bool {
|
|||||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||||
// that tool's own status normalization.
|
// that tool's own status normalization.
|
||||||
func planStatusRemaining(status string) bool {
|
func planStatusRemaining(status string) bool {
|
||||||
switch normalizeTaskPlanStatus(status) {
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
case "completed", "failed":
|
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||||
|
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -305,8 +307,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
|
|||||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||||
// passing treated as the objective being met; and a result that merely matches a
|
// passing treated as the objective being met; and a result that merely matches a
|
||||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||||
func acceptanceVerificationNudge(objective string) string {
|
func acceptanceVerificationNudge() string {
|
||||||
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||||
"result merely matches a baseline you were asked to beat or improve. " +
|
"result merely matches a baseline you were asked to beat or improve. " +
|
||||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||||
@@ -314,10 +316,6 @@ func acceptanceVerificationNudge(objective string) string {
|
|||||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||||
"If that check passes, reply PASS and cite the evidence. " +
|
"If that check passes, reply PASS and cite the evidence. " +
|
||||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||||
if objective = capTaskObjective(objective); objective != "" {
|
|
||||||
nudge += "\n\nTask objective: " + objective
|
|
||||||
}
|
|
||||||
return nudge
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolFailureHintMarker is a stable substring for tests.
|
// toolFailureHintMarker is a stable substring for tests.
|
||||||
@@ -555,12 +553,16 @@ func (state *guardState) pendingPlanItems() bool {
|
|||||||
// many items are still remaining. Malformed arguments leave the prior count
|
// many items are still remaining. Malformed arguments leave the prior count
|
||||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||||
func (state *guardState) observePlanUpdate(arguments string) {
|
func (state *guardState) observePlanUpdate(arguments string) {
|
||||||
plan, ok := parseTaskPlan(arguments)
|
var parsed struct {
|
||||||
if !ok {
|
Plan []struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"plan"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending := 0
|
pending := 0
|
||||||
for _, item := range plan {
|
for _, item := range parsed.Plan {
|
||||||
if planStatusRemaining(item.Status) {
|
if planStatusRemaining(item.Status) {
|
||||||
pending++
|
pending++
|
||||||
}
|
}
|
||||||
@@ -591,12 +593,13 @@ func (state *guardState) planReminder(turn int) string {
|
|||||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
||||||
// sustained work should have called update_plan. One-shot for the whole run.
|
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
||||||
|
// One-shot for the whole run.
|
||||||
if !state.notCalledReminderSent &&
|
if !state.notCalledReminderSent &&
|
||||||
!state.planEverCalled &&
|
!state.planEverCalled &&
|
||||||
turn >= planReminderTurn &&
|
turn >= planReminderTurn &&
|
||||||
state.totalToolCalls >= planReminderToolThreshold {
|
state.totalToolCalls >= 1 {
|
||||||
state.notCalledReminderSent = true
|
state.notCalledReminderSent = true
|
||||||
return planNotCalledReminder()
|
return planNotCalledReminder()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
turns: [][]zeroruntime.StreamEvent{
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
@@ -169,37 +169,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) {
|
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||||
var state guardState
|
var state guardState
|
||||||
toolOnly := zeroruntime.CollectedStream{
|
toolOnly := zeroruntime.CollectedStream{
|
||||||
@@ -257,16 +226,19 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||||
const expectedThreshold = 7
|
|
||||||
if planReminderToolThreshold != expectedThreshold {
|
|
||||||
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
|
|
||||||
}
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
|
|
||||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
|
||||||
|
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
|
||||||
|
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
|
||||||
|
textTurn("done"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
result, err := Run(context.Background(), "go", provider, Options{
|
result, err := Run(context.Background(), "go", provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
@@ -284,49 +256,11 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
|
|
||||||
const expectedThreshold = 7
|
|
||||||
root := t.TempDir()
|
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
|
||||||
|
|
||||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
|
|
||||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.FinalAnswer != "done" {
|
|
||||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
|
||||||
}
|
|
||||||
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
|
|
||||||
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
|
|
||||||
state := guardState{totalToolCalls: 7}
|
|
||||||
if got := state.planReminder(6); got != "" {
|
|
||||||
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
|
|
||||||
}
|
|
||||||
if state.notCalledReminderSent {
|
|
||||||
t.Fatal("early turn marked the not-called reminder as sent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
|
|
||||||
turns := make([][]zeroruntime.StreamEvent, 0, count)
|
|
||||||
for range count {
|
|
||||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
|
||||||
}
|
|
||||||
return turns
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
turns: [][]zeroruntime.StreamEvent{
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
@@ -354,7 +288,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
@@ -384,7 +318,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
|
||||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||||
@@ -418,7 +352,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
|
||||||
turns := [][]zeroruntime.StreamEvent{
|
turns := [][]zeroruntime.StreamEvent{
|
||||||
@@ -453,7 +387,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
registry.Register(tools.NewReadFileTool(root))
|
||||||
|
|
||||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||||
|
|||||||
+210
-539
File diff suppressed because it is too large.
Load diff
+59
-897
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,16 +19,6 @@ type propagationOutputTool struct {
|
|||||||
|
|
||||||
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||||
for index, arg := range os.Args {
|
for index, arg := range os.Args {
|
||||||
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
|
|
||||||
repeats, err := strconv.Atoi(os.Args[index+1])
|
|
||||||
if err != nil {
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -41,102 +30,25 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
||||||
return outputBudgetHookDispatcherFor("propagation_output", 200)
|
feedback := strings.Repeat("hook feedback ", 200)
|
||||||
}
|
|
||||||
|
|
||||||
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
|
|
||||||
feedback := strings.Repeat("hook feedback ", repeats)
|
|
||||||
hookArgs := []string{
|
|
||||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
|
||||||
"--",
|
|
||||||
"--zero-output-budget-hook",
|
|
||||||
feedback,
|
|
||||||
}
|
|
||||||
if repeats > 1_000 {
|
|
||||||
hookArgs = []string{
|
|
||||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
|
||||||
"--",
|
|
||||||
"--zero-output-budget-hook-repeat",
|
|
||||||
strconv.Itoa(repeats),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Hooks: []hooks.Definition{{
|
Hooks: []hooks.Definition{{
|
||||||
ID: "large-feedback",
|
ID: "large-feedback",
|
||||||
Event: hooks.EventAfterTool,
|
Event: hooks.EventAfterTool,
|
||||||
Matcher: toolName,
|
Matcher: "propagation_output",
|
||||||
Command: os.Args[0],
|
Command: os.Args[0],
|
||||||
Args: hookArgs,
|
Args: []string{
|
||||||
|
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||||
|
"--",
|
||||||
|
"--zero-output-budget-hook",
|
||||||
|
feedback,
|
||||||
|
},
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}},
|
}},
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
targetPath := filepath.Join(root, "target.txt")
|
|
||||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
linkPath := filepath.Join(root, "small.txt")
|
|
||||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
|
||||||
t.Skipf("symlink unavailable: %v", err)
|
|
||||||
}
|
|
||||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
|
||||||
tracker := tools.NewFileTracker()
|
|
||||||
|
|
||||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
|
||||||
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
|
||||||
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
|
|
||||||
if abortErr != nil || result.Status != tools.StatusOK {
|
|
||||||
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
|
|
||||||
}
|
|
||||||
if !tracker.SeenWhole(resolvedPath) {
|
|
||||||
t.Fatal("unmodified final read output was not committed as observed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
|
|
||||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
|
||||||
root := t.TempDir()
|
|
||||||
targetPath := filepath.Join(root, "target.txt")
|
|
||||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
linkPath := filepath.Join(root, "small.txt")
|
|
||||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
|
||||||
t.Skipf("symlink unavailable: %v", err)
|
|
||||||
}
|
|
||||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
|
||||||
tracker := tools.NewFileTracker()
|
|
||||||
|
|
||||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
|
||||||
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
|
||||||
}, PermissionModeAuto, Options{
|
|
||||||
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
|
|
||||||
})
|
|
||||||
if abortErr != nil {
|
|
||||||
t.Fatalf("executeToolCall: %v", abortErr)
|
|
||||||
}
|
|
||||||
if !result.Truncated {
|
|
||||||
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
|
|
||||||
}
|
|
||||||
if tracker.SeenWhole(resolvedPath) {
|
|
||||||
t.Fatal("read observation committed before hook output was budgeted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tool propagationOutputTool) Name() string { return "propagation_output" }
|
func (tool propagationOutputTool) Name() string { return "propagation_output" }
|
||||||
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
||||||
func (tool propagationOutputTool) Parameters() tools.Schema {
|
func (tool propagationOutputTool) Parameters() tools.Schema {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
|||||||
// "read_file".
|
// "read_file".
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
|
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||||
registry.Register(fakeToolSearchTool{})
|
registry.Register(fakeToolSearchTool{})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||||
|
|||||||
@@ -31,19 +31,6 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
|||||||
return tools.Result{Status: tools.StatusOK}
|
return tools.Result{Status: tools.StatusOK}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
|
|
||||||
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
|
|
||||||
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
|
|
||||||
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
|
|
||||||
if custom.Type != "" {
|
|
||||||
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||||
// output is identical to the uncached path.
|
// output is identical to the uncached path.
|
||||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||||
|
|||||||
@@ -68,61 +68,3 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
|||||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
|
|
||||||
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
|
|
||||||
args := map[string]any{
|
|
||||||
"command": "npm install",
|
|
||||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
|
||||||
"additional_permissions": map[string]any{
|
|
||||||
"network": map[string]any{"enabled": true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
|
||||||
|
|
||||||
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
|
|
||||||
t.Fatal("an already-covered capability must not trigger another prompt")
|
|
||||||
}
|
|
||||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("covered capability should still produce an auditable allow event")
|
|
||||||
}
|
|
||||||
if event.Action != PermissionActionAllow {
|
|
||||||
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
|
|
||||||
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
|
|
||||||
args := map[string]any{"command": "make test"}
|
|
||||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
|
|
||||||
|
|
||||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("generic shell approval must produce an event")
|
|
||||||
}
|
|
||||||
if event.Reason != "" {
|
|
||||||
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
|
|
||||||
}
|
|
||||||
if event.DecisionReason != "runs a shell command" {
|
|
||||||
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
|
|
||||||
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
|
|
||||||
args := map[string]any{
|
|
||||||
"command": "curl https://example.test",
|
|
||||||
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
|
|
||||||
"justification": "Run without sandboxing.",
|
|
||||||
}
|
|
||||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
|
|
||||||
|
|
||||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("policy approval must produce an event")
|
|
||||||
}
|
|
||||||
if event.Reason != sandbox.ReasonNetworkBlocked {
|
|
||||||
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
)
|
|
||||||
|
|
||||||
// escalatingEngine allows unsandboxed escalation: no denied reads, so
|
|
||||||
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
|
|
||||||
// call to require_escalated.
|
|
||||||
func escalatingEngine(t *testing.T) *sandbox.Engine {
|
|
||||||
t.Helper()
|
|
||||||
policy := sandbox.DefaultPolicy()
|
|
||||||
policy.DenyRead = nil
|
|
||||||
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
|
|
||||||
}
|
|
||||||
|
|
||||||
// nonEscalatingEngine has a denied read, which is exactly the condition under
|
|
||||||
// which escalation is refused, so no disclosure should be made.
|
|
||||||
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
policy := sandbox.DefaultPolicy()
|
|
||||||
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
|
||||||
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
|
|
||||||
}
|
|
||||||
|
|
||||||
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
|
|
||||||
// agree on every input, because the defect being fixed is precisely that the
|
|
||||||
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
|
|
||||||
// only "allow command prefix for session".
|
|
||||||
//
|
|
||||||
// Asserting agreement rather than asserting each side's expected value is what
|
|
||||||
// makes this hold up. If someone later widens or narrows the rewrite's guards
|
|
||||||
// and forgets the flag, the two diverge here and this fails, which a pair of
|
|
||||||
// independent expected-value tests would not catch.
|
|
||||||
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
|
|
||||||
escalating := Options{Sandbox: escalatingEngine(t)}
|
|
||||||
denied := Options{Sandbox: nonEscalatingEngine(t)}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
toolName string
|
|
||||||
args map[string]any
|
|
||||||
options Options
|
|
||||||
}{
|
|
||||||
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
|
|
||||||
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
|
|
||||||
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
|
|
||||||
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
|
|
||||||
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
|
|
||||||
{
|
|
||||||
"call already asks for escalation",
|
|
||||||
"bash",
|
|
||||||
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
|
|
||||||
escalating,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"call already asks for additional permissions",
|
|
||||||
"bash",
|
|
||||||
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
|
|
||||||
escalating,
|
|
||||||
},
|
|
||||||
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
|
|
||||||
// the disclosure has to fire. Included because the obvious reading of
|
|
||||||
// the guards says otherwise, and a case asserting the wrong thing here
|
|
||||||
// would have passed silently.
|
|
||||||
{
|
|
||||||
"unrecognized sandbox_permissions value",
|
|
||||||
"bash",
|
|
||||||
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
|
|
||||||
escalating,
|
|
||||||
},
|
|
||||||
{"empty args", "bash", map[string]any{}, escalating},
|
|
||||||
{"nil args", "bash", nil, escalating},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range cases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
|
|
||||||
|
|
||||||
// What the user's approval would actually be turned into.
|
|
||||||
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
|
|
||||||
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
|
|
||||||
|
|
||||||
if disclosed != escalates {
|
|
||||||
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two prefix decisions are the only ones that escalate, so a disclosure
|
|
||||||
// attached to a request offering neither would be a false alarm. Checked
|
|
||||||
// against the same predicate the rewrite consults.
|
|
||||||
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
|
|
||||||
escalating := map[PermissionDecisionAction]bool{
|
|
||||||
PermissionDecisionAllowPrefix: true,
|
|
||||||
PermissionDecisionAlwaysAllowPrefix: true,
|
|
||||||
}
|
|
||||||
for _, action := range []PermissionDecisionAction{
|
|
||||||
PermissionDecisionAllow,
|
|
||||||
PermissionDecisionAllowStrict,
|
|
||||||
PermissionDecisionAllowForSession,
|
|
||||||
PermissionDecisionAllowPrefix,
|
|
||||||
PermissionDecisionAlwaysAllowPrefix,
|
|
||||||
PermissionDecisionAlwaysAllow,
|
|
||||||
PermissionDecisionDeny,
|
|
||||||
PermissionDecisionCancel,
|
|
||||||
} {
|
|
||||||
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
|
|
||||||
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -91,7 +91,7 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
|||||||
if c.policy == nil || c.escalated {
|
if c.policy == nil || c.escalated {
|
||||||
return PostureEscalation{}, false
|
return PostureEscalation{}, false
|
||||||
}
|
}
|
||||||
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
|
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
|
||||||
return PostureEscalation{}, false
|
return PostureEscalation{}, false
|
||||||
}
|
}
|
||||||
c.escalated = true
|
c.escalated = true
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||||
@@ -16,43 +14,13 @@ import (
|
|||||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||||
//
|
//
|
||||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||||
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
|
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||||
// the auto-mode advertised set, including exec_command: auto mode must expose both
|
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||||
// process creation and write_stdin process interaction or the latter is unusable
|
// only advertise in other modes are excluded, matching what a real turn actually
|
||||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
// pays.
|
||||||
const (
|
const (
|
||||||
maxBaseSystemPromptTokens = 3500
|
maxBaseSystemPromptTokens = 3500
|
||||||
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
|
maxEagerToolSchemaTokens = 2700
|
||||||
// to 3588, and #867 trimmed it back to 3578: still 28 tokens over.
|
|
||||||
//
|
|
||||||
// The overrun is a rounding error rather than a regression. view_image costs
|
|
||||||
// 82 tokens, the second cheapest tool in the set, against exec_command's 909
|
|
||||||
// and request_permissions' 397; it is simply the one that crossed the line.
|
|
||||||
// Raised rather than paid down because the alternative was trimming a
|
|
||||||
// description to claw back 28 tokens, degrading a tool's usability to satisfy
|
|
||||||
// a line drawn against a 2026-07 measurement that predates two tools.
|
|
||||||
//
|
|
||||||
// The ratchet did its job: it caught the creep and forced a decision instead
|
|
||||||
// of a drift. That is the point of it, so keep raising it deliberately rather
|
|
||||||
// than reflexively.
|
|
||||||
//
|
|
||||||
// THIS COUNTS THE SCHEMAS ONLY, NOT THE HOST'S SHELL GUIDANCE. Until
|
|
||||||
// 2026-09-07 it counted both, and that made it a different measurement on
|
|
||||||
// every machine: exec_command appends shell guidance whose size depends on
|
|
||||||
// which shell was detected, so the same tree measured 3498 on Linux, ~3600 on
|
|
||||||
// a Windows host with pwsh 7, and 3653 on Windows PowerShell 5.1, which is
|
|
||||||
// stock Windows. The 5.1 case has been over this ceiling since view_image
|
|
||||||
// landed, so `go test ./...` failed for anyone on a stock Windows box while
|
|
||||||
// CI stayed green, because the runners have pwsh 7 and take the cheaper
|
|
||||||
// branch. A budget that passes or fails on which PowerShell happens to be
|
|
||||||
// installed is not ratcheting anything.
|
|
||||||
maxEagerToolSchemaTokens = 3650
|
|
||||||
// The host-specific half, ratcheted on its own so it cannot creep either.
|
|
||||||
// Measured 2026-09-07: Windows PowerShell 5.1 is the most expensive host at
|
|
||||||
// 155 tokens (722 chars), pwsh 7 is 110, cmd.exe is 87, and every POSIX host
|
|
||||||
// is 0 because no guidance is appended there. Set above the worst case, not
|
|
||||||
// above whatever this machine happens to be.
|
|
||||||
maxExecCommandShellGuidanceTokens = 200
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||||
@@ -75,62 +43,9 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
|||||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||||
total := estimateToolDefTokens(exposed)
|
got := estimateToolDefTokens(exposed)
|
||||||
// exec_command's description carries this host's shell guidance, and that
|
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||||
// guidance is the one part of the eager set that is not the same everywhere.
|
|
||||||
// Charged separately below rather than folded in here, or this ceiling would
|
|
||||||
// mean a different thing on every machine. See the constants above.
|
|
||||||
//
|
|
||||||
// Removed from the description BEFORE estimating rather than subtracted from
|
|
||||||
// the total afterwards. ApproxTextTokens is an integer division, so
|
|
||||||
// floor((schema+guidance)/4) - floor(guidance/4) comes out one token high
|
|
||||||
// whenever the two remainders add past four, and which side of that line a
|
|
||||||
// host lands on depends on the length of its guidance: exactly the
|
|
||||||
// host-dependence this number is supposed to be rid of.
|
|
||||||
guidance := tools.HostExecCommandShellGuidance()
|
|
||||||
schemas := make([]zeroruntime.ToolDefinition, len(exposed))
|
|
||||||
copy(schemas, exposed)
|
|
||||||
stripped := 0
|
|
||||||
for index := range schemas {
|
|
||||||
without := strings.TrimSuffix(schemas[index].Description, guidance)
|
|
||||||
if without != schemas[index].Description {
|
|
||||||
stripped++
|
|
||||||
}
|
|
||||||
schemas[index].Description = without
|
|
||||||
}
|
|
||||||
if guidance != "" && stripped != 1 {
|
|
||||||
t.Fatalf("SETUP INVALID: this host's shell guidance came off %d descriptions, want exactly exec_command's; internal/tools pins it as that description's tail", stripped)
|
|
||||||
}
|
|
||||||
got := estimateToolDefTokens(schemas)
|
|
||||||
guidanceTokens := ApproxTextTokens(guidance)
|
|
||||||
t.Logf("eager core tool schemas: %d tokens across %d tools (%d total, %d host shell guidance)", got, len(exposed), total, guidanceTokens)
|
|
||||||
if got > maxEagerToolSchemaTokens {
|
if got > maxEagerToolSchemaTokens {
|
||||||
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
|
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||||
// tool deferred leaves it exposed here and changes nothing. Deferral is
|
|
||||||
// still worth doing for real sessions, it just cannot move this number.
|
|
||||||
// The levers that do are a smaller schema, one fewer core tool, or a
|
|
||||||
// deliberate raise.
|
|
||||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
|
||||||
}
|
|
||||||
if guidanceTokens > maxExecCommandShellGuidanceTokens {
|
|
||||||
t.Fatalf("this host's exec_command shell guidance is %d tokens, over the %d ceiling — trim the guidance in internal/tools/shell_runtime.go or raise the ceiling deliberately", guidanceTokens, maxExecCommandShellGuidanceTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentAdvertisesBothEditTools(t *testing.T) {
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
|
||||||
registry.Register(tool)
|
|
||||||
}
|
|
||||||
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
|
|
||||||
names := make(map[string]bool, len(exposed))
|
|
||||||
for _, definition := range exposed {
|
|
||||||
names[definition.Name] = true
|
|
||||||
}
|
|
||||||
if !names["apply_patch"] {
|
|
||||||
t.Fatal("agent must retain apply_patch for multi-hunk changes")
|
|
||||||
}
|
|
||||||
if !names["edit_file"] {
|
|
||||||
t.Fatal("agent must receive edit_file for targeted exact replacements")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,11 +11,12 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// promptSubstrings are the cacheable components of the prompt a run sends to a
|
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
||||||
// model. systemPrompt is the exact joined system-message content. The remaining
|
// sends to a model. Each is the literal content the sub-component would emit
|
||||||
// fields retain useful diagnostic boundaries within that prompt and tool list.
|
// into the system prompt or tool list, hashed so a downstream observer can
|
||||||
|
// detect drift turn-over-turn. The substrings are produced by
|
||||||
|
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
||||||
type promptSubstrings struct {
|
type promptSubstrings struct {
|
||||||
systemPrompt string
|
|
||||||
baseInstructions string
|
baseInstructions string
|
||||||
confirmationPolicy string
|
confirmationPolicy string
|
||||||
projectContext string
|
projectContext string
|
||||||
@@ -24,61 +25,92 @@ type promptSubstrings struct {
|
|||||||
schema string
|
schema string
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPromptSubstrings builds the prompt and retains both its exact joined
|
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
||||||
// form and narrower diagnostic components. Callers that already built the
|
// prompt without joining them. The system prompt sections are produced by the
|
||||||
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
|
// same private builders that buildSystemPrompt uses, so the substrings are
|
||||||
// reads.
|
// byte-identical to what would have appeared in the joined prompt. The tools
|
||||||
|
// and schema substrings are derived from the partitioned tool list (caller-
|
||||||
|
// provided) so this helper does not need to re-run the partition.
|
||||||
|
//
|
||||||
|
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
||||||
|
// separate function (rather than returning both prompt and substrings from
|
||||||
|
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
||||||
|
// and the substrings helper is independently testable.
|
||||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||||
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
|
core := strings.TrimSpace(options.SystemPrompt)
|
||||||
}
|
if core == "" {
|
||||||
|
core = strings.TrimSpace(coreSystemPrompt)
|
||||||
|
}
|
||||||
|
if core == "" {
|
||||||
|
core = fallbackSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
||||||
|
// the substring here is the same constant.
|
||||||
|
policy := strings.TrimSpace(confirmationPolicy)
|
||||||
|
|
||||||
|
// projectContext is the joined output of workspaceContext, which itself
|
||||||
|
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
||||||
|
// joined string captures every guideline file's contribution and the
|
||||||
|
// ordering between them.
|
||||||
|
project := workspaceContext(options.Cwd)
|
||||||
|
|
||||||
|
// skills is the joined output of skillsContext, with each skill on its own
|
||||||
|
// line. The substring is the literal text the model would see.
|
||||||
|
skills := skillsContext(options)
|
||||||
|
|
||||||
|
// tools and schema are derived from the partitioned tool list. The list
|
||||||
|
// passed in is already in stable order (partitionToolsCached is the
|
||||||
|
// canonical stable partitioner) so concatenating in slice order is
|
||||||
|
// sufficient — we sort names again defensively in case a future caller
|
||||||
|
// hands us a list from a different partitioner.
|
||||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||||
|
|
||||||
return promptSubstrings{
|
return promptSubstrings{
|
||||||
systemPrompt: parts.prompt,
|
baseInstructions: core,
|
||||||
baseInstructions: parts.baseInstructions,
|
confirmationPolicy: policy,
|
||||||
confirmationPolicy: parts.confirmationPolicy,
|
projectContext: project,
|
||||||
projectContext: parts.projectContext,
|
skills: skills,
|
||||||
skills: parts.skills,
|
|
||||||
tools: toolsSubstr,
|
tools: toolsSubstr,
|
||||||
schema: schemaSubstr,
|
schema: schemaSubstr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolSubstrings preserves the emitted tool order. Names and descriptions are
|
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
||||||
// fingerprinted separately from schemas so either kind of drift is identifiable.
|
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
||||||
// Order is part of request identity: silently sorting here would hide a provider-
|
// the partition produces a different hash. The schema substring is the
|
||||||
// visible reordering and could report a false cache hit.
|
// concatenation of each tool's JSON schema in name order, so a schema change
|
||||||
|
// is detected independently of the tool ordering.
|
||||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||||
if len(exposed) == 0 {
|
if len(exposed) == 0 {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
var toolsSB, schemaSB strings.Builder
|
names := make([]string, 0, len(exposed))
|
||||||
|
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
||||||
for _, def := range exposed {
|
for _, def := range exposed {
|
||||||
writeLengthPrefixed(&toolsSB, def.Name)
|
names = append(names, def.Name)
|
||||||
writeLengthPrefixed(&toolsSB, def.Description)
|
byName[def.Name] = def
|
||||||
writeLengthPrefixed(&toolsSB, string(def.Type))
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var toolsSB, schemaSB strings.Builder
|
||||||
|
for i, name := range names {
|
||||||
|
if i > 0 {
|
||||||
|
toolsSB.WriteByte('\n')
|
||||||
|
}
|
||||||
|
toolsSB.WriteString(name)
|
||||||
|
def := byName[name]
|
||||||
// Parameters is rendered to a canonical JSON string per tool. The
|
// Parameters is rendered to a canonical JSON string per tool. The
|
||||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||||
// guarantees the same render is byte-identical across turns for the
|
// guarantees the same render is byte-identical across turns for the
|
||||||
// same tool, so this substring is a stable hash input.
|
// same tool, so this substring is a stable hash input.
|
||||||
writeLengthPrefixed(&schemaSB, def.Name)
|
schemaSB.WriteString(name)
|
||||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
schemaSB.WriteByte('\n')
|
||||||
if def.Format != nil {
|
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
||||||
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
schemaSB.WriteByte('\n')
|
||||||
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
|
||||||
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return toolsSB.String(), schemaSB.String()
|
return toolsSB.String(), schemaSB.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLengthPrefixed(sb *strings.Builder, value string) {
|
|
||||||
fmt.Fprintf(sb, "%d:", len(value))
|
|
||||||
sb.WriteString(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||||
@@ -195,16 +227,36 @@ func writeStable(sb *strings.Builder, v any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
|
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
||||||
// not already have one. The agent loop uses computePrefixFingerprint with the
|
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
||||||
// prompt parts it built once at run start, ensuring tracing observes the exact
|
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
||||||
// request content without repeating workspace I/O.
|
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
||||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
// sub-component drift is observable both individually and in aggregate.
|
||||||
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
|
//
|
||||||
}
|
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
||||||
|
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
||||||
|
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
||||||
|
// context work is performed separately by buildSystemPrompt a few lines
|
||||||
|
// later in the request-build path, so on every traced turn the file and
|
||||||
|
// git reads happen twice. The duplication is intentional for this PR (a
|
||||||
|
// follow-up will pass substrings through) and is a known perf cost only on
|
||||||
|
// the opt-in trace path.
|
||||||
|
//
|
||||||
|
// A stable CompletePrefixHash across turns means the four captured
|
||||||
|
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
||||||
|
// skills) are byte-identical. It does NOT rule out drift in the seven
|
||||||
|
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
||||||
|
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
||||||
|
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
||||||
|
// default Options the four captured substrings are the full prompt; for
|
||||||
|
// non-default Options the seven uncaptured sections may contribute, and a
|
||||||
|
// consumer correlating CompletePrefix stability with cached_input_tokens
|
||||||
|
// must cross-check the model_switches counter (modelPromptAddendum changes
|
||||||
|
// on a model switch) to disambiguate "no drift" from "drift in an
|
||||||
|
// uncaptured section."
|
||||||
|
|
||||||
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||||
systemPrompt := sha256hex(subs.systemPrompt)
|
subs := buildPromptSubstrings(options, exposed)
|
||||||
base := sha256hex(subs.baseInstructions)
|
base := sha256hex(subs.baseInstructions)
|
||||||
policy := sha256hex(subs.confirmationPolicy)
|
policy := sha256hex(subs.confirmationPolicy)
|
||||||
project := sha256hex(subs.projectContext)
|
project := sha256hex(subs.projectContext)
|
||||||
@@ -212,10 +264,9 @@ func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
|||||||
toolsH := sha256hex(subs.tools)
|
toolsH := sha256hex(subs.tools)
|
||||||
schema := sha256hex(subs.schema)
|
schema := sha256hex(subs.schema)
|
||||||
complete := sha256hex(strings.Join([]string{
|
complete := sha256hex(strings.Join([]string{
|
||||||
systemPrompt, toolsH, schema,
|
base, policy, project, skills, toolsH, schema,
|
||||||
}, "|"))
|
}, "|"))
|
||||||
return prefixFingerprint{
|
return prefixFingerprint{
|
||||||
SystemPromptHash: systemPrompt,
|
|
||||||
BaseInstructionsHash: base,
|
BaseInstructionsHash: base,
|
||||||
ConfirmationPolicyHash: policy,
|
ConfirmationPolicyHash: policy,
|
||||||
ProjectContextHash: project,
|
ProjectContextHash: project,
|
||||||
@@ -231,7 +282,6 @@ func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
|||||||
// in loop.go) so the trace package does not need to import this one. The field
|
// in loop.go) so the trace package does not need to import this one. The field
|
||||||
// names match the trace.PrefixHash JSON tags 1:1.
|
// names match the trace.PrefixHash JSON tags 1:1.
|
||||||
type prefixFingerprint struct {
|
type prefixFingerprint struct {
|
||||||
SystemPromptHash string
|
|
||||||
BaseInstructionsHash string
|
BaseInstructionsHash string
|
||||||
ConfirmationPolicyHash string
|
ConfirmationPolicyHash string
|
||||||
ProjectContextHash string
|
ProjectContextHash string
|
||||||
|
|||||||
@@ -28,28 +28,38 @@ func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool order and descriptions are provider-visible and therefore part of the
|
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
||||||
// cacheable prefix identity.
|
// name-set sensitivity of the ToolsHash. The test name used to read as
|
||||||
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
|
// "reorder changes hash" but the assertion is the opposite: the hash is
|
||||||
|
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
||||||
|
// names before hashing). The partitioner is expected to produce a stable
|
||||||
|
// order, so a name-order change is not a hash signal — a name-set change
|
||||||
|
// is. The test asserts both: permutation of the same tools produces the
|
||||||
|
// same hash, and adding a tool produces a different hash.
|
||||||
|
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
||||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||||
a := []zeroruntime.ToolDefinition{
|
a := []zeroruntime.ToolDefinition{
|
||||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||||
}
|
}
|
||||||
b := []zeroruntime.ToolDefinition{
|
b := []zeroruntime.ToolDefinition{
|
||||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||||
}
|
}
|
||||||
|
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
||||||
|
// for a permutation of the same tools. We assert that here to document
|
||||||
|
// the contract: name-order independence, name-set sensitivity.
|
||||||
fpA := ComputePrefixFingerprint(opts, a)
|
fpA := ComputePrefixFingerprint(opts, a)
|
||||||
fpB := ComputePrefixFingerprint(opts, b)
|
fpB := ComputePrefixFingerprint(opts, b)
|
||||||
if fpA.ToolsHash == fpB.ToolsHash {
|
if fpA.ToolsHash != fpB.ToolsHash {
|
||||||
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||||
}
|
}
|
||||||
c := append([]zeroruntime.ToolDefinition(nil), a...)
|
// A different tool set must produce a different ToolsHash.
|
||||||
c[0].Description = "read a file"
|
c := append([]zeroruntime.ToolDefinition{}, a...)
|
||||||
|
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
||||||
fpC := ComputePrefixFingerprint(opts, c)
|
fpC := ComputePrefixFingerprint(opts, c)
|
||||||
if fpA.ToolsHash == fpC.ToolsHash {
|
if fpA.ToolsHash == fpC.ToolsHash {
|
||||||
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,38 +83,6 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
|
|
||||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
|
||||||
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
|
|
||||||
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
|
|
||||||
|
|
||||||
typed := base
|
|
||||||
typed.Type = zeroruntime.ToolDefinitionFreeform
|
|
||||||
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
|
|
||||||
t.Fatal("ToolsHash must change when tool Type changes")
|
|
||||||
}
|
|
||||||
|
|
||||||
formatCases := []struct {
|
|
||||||
name string
|
|
||||||
format zeroruntime.ToolDefinitionFormat
|
|
||||||
}{
|
|
||||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
|
||||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
|
||||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
|
|
||||||
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
|
|
||||||
}
|
|
||||||
for _, test := range formatCases {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
withFormat := base
|
|
||||||
withFormat.Format = &test.format
|
|
||||||
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
|
|
||||||
if got.SchemaHash == baseFingerprint.SchemaHash {
|
|
||||||
t.Fatalf("SchemaHash must change for %s format input", test.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||||
// model addendum change) must move the base-instructions hash, which moves
|
// model addendum change) must move the base-instructions hash, which moves
|
||||||
@@ -123,50 +101,25 @@ func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletePrefixHash represents the exact joined system prompt and the ordered
|
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
||||||
// tool definitions. Narrower system-section hashes remain diagnostic only.
|
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
||||||
|
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
||||||
|
// from the canonical join without updating CompletePrefixHash."
|
||||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||||
fp := ComputePrefixFingerprint(opts, exposed)
|
fp := ComputePrefixFingerprint(opts, exposed)
|
||||||
// Manually compute what the canonical join should be.
|
// Manually compute what the canonical join should be.
|
||||||
expected := sha256hex(strings.Join([]string{
|
expected := sha256hex(strings.Join([]string{
|
||||||
fp.SystemPromptHash,
|
fp.BaseInstructionsHash,
|
||||||
|
fp.ConfirmationPolicyHash,
|
||||||
|
fp.ProjectContextHash,
|
||||||
|
fp.SkillsHash,
|
||||||
fp.ToolsHash,
|
fp.ToolsHash,
|
||||||
fp.SchemaHash,
|
fp.SchemaHash,
|
||||||
}, "|"))
|
}, "|"))
|
||||||
if fp.CompletePrefixHash != expected {
|
if fp.CompletePrefixHash != expected {
|
||||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
|
||||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
|
|
||||||
changed := base
|
|
||||||
changed.ResponseStyle = "concise"
|
|
||||||
|
|
||||||
fpBase := ComputePrefixFingerprint(base, nil)
|
|
||||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
|
||||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
|
||||||
t.Fatal("SystemPromptHash must change when any rendered system section changes")
|
|
||||||
}
|
|
||||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
|
||||||
t.Fatal("CompletePrefixHash must include the exact full system prompt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
|
|
||||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
|
||||||
changed := base
|
|
||||||
changed.TransientSystemPrompt = "turn-only guidance"
|
|
||||||
|
|
||||||
fpBase := ComputePrefixFingerprint(base, nil)
|
|
||||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
|
||||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
|
||||||
t.Fatal("SystemPromptHash must change for transient system guidance")
|
|
||||||
}
|
|
||||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
|
||||||
t.Fatal("CompletePrefixHash must cover transient system guidance")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +141,6 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
|||||||
if subs.baseInstructions != "test core" {
|
if subs.baseInstructions != "test core" {
|
||||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||||
}
|
}
|
||||||
if subs.systemPrompt != buildSystemPrompt(opts) {
|
|
||||||
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
|
|
||||||
}
|
|
||||||
if subs.confirmationPolicy == "" {
|
if subs.confirmationPolicy == "" {
|
||||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||||
}
|
}
|
||||||
@@ -201,9 +151,6 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
|||||||
// sha256hex of the substrings, with no truncation or reformatting
|
// sha256hex of the substrings, with no truncation or reformatting
|
||||||
// between the two layers.
|
// between the two layers.
|
||||||
fp := ComputePrefixFingerprint(opts, nil)
|
fp := ComputePrefixFingerprint(opts, nil)
|
||||||
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
|
|
||||||
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
|
|
||||||
}
|
|
||||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,67 +121,3 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
|
|||||||
func isPrunedPlaceholder(content string) bool {
|
func isPrunedPlaceholder(content string) bool {
|
||||||
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
||||||
}
|
}
|
||||||
|
|
||||||
var supersedableReadTools = map[string]bool{
|
|
||||||
"read_file": true,
|
|
||||||
"read_minified_file": true,
|
|
||||||
"grep": true,
|
|
||||||
"glob": true,
|
|
||||||
"list_directory": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneSupersededReadResults removes only older byte-identical results of the
|
|
||||||
// same read call. It runs when context pressure already requires a rewrite, so
|
|
||||||
// it does not trade a warm provider-cache prefix for a small apparent saving.
|
|
||||||
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
|
|
||||||
type seenResultKey struct {
|
|
||||||
call string
|
|
||||||
content string
|
|
||||||
}
|
|
||||||
seen := map[seenResultKey]struct{}{}
|
|
||||||
out := messages
|
|
||||||
reclaimed := 0
|
|
||||||
copied := false
|
|
||||||
|
|
||||||
for index := len(messages) - 1; index >= 0; index-- {
|
|
||||||
message := messages[index]
|
|
||||||
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
call, ok := toolCallForResult(messages, index)
|
|
||||||
if !ok || !supersedableReadTools[call.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := seenResultKey{
|
|
||||||
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
|
|
||||||
content: message.Content,
|
|
||||||
}
|
|
||||||
if _, exists := seen[key]; exists {
|
|
||||||
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
|
|
||||||
if !copied {
|
|
||||||
out = append([]zeroruntime.Message(nil), messages...)
|
|
||||||
copied = true
|
|
||||||
}
|
|
||||||
out[index].Content = placeholder
|
|
||||||
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
}
|
|
||||||
return out, reclaimed
|
|
||||||
}
|
|
||||||
|
|
||||||
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
|
|
||||||
id := messages[resultIndex].ToolCallID
|
|
||||||
if id == "" {
|
|
||||||
return zeroruntime.ToolCall{}, false
|
|
||||||
}
|
|
||||||
for index := resultIndex - 1; index >= 0; index-- {
|
|
||||||
for _, call := range messages[index].ToolCalls {
|
|
||||||
if call.ID == id {
|
|
||||||
return call, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return zeroruntime.ToolCall{}, false
|
|
||||||
}
|
|
||||||
@@ -25,13 +25,6 @@ func toolCallMsg(id, name string) zeroruntime.Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
|
|
||||||
return zeroruntime.Message{
|
|
||||||
Role: zeroruntime.MessageRoleAssistant,
|
|
||||||
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPruneSkipsSmallSessions(t *testing.T) {
|
func TestPruneSkipsSmallSessions(t *testing.T) {
|
||||||
// Total reclaimable is under the gate → no pruning.
|
// Total reclaimable is under the gate → no pruning.
|
||||||
msgs := []zeroruntime.Message{
|
msgs := []zeroruntime.Message{
|
||||||
@@ -127,69 +120,3 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
|
|||||||
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
|
|
||||||
body := strings.Repeat("same source line\n", 1000)
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
|
||||||
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
|
|
||||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
|
||||||
}
|
|
||||||
out, reclaimed := pruneSupersededReadResults(messages)
|
|
||||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
|
||||||
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
|
||||||
}
|
|
||||||
if out[3].Content != body || out[5].Content != body {
|
|
||||||
t.Fatal("different call and newest result must remain exact")
|
|
||||||
}
|
|
||||||
if messages[1].Content != body {
|
|
||||||
t.Fatal("input slice was mutated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
|
|
||||||
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
|
|
||||||
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
|
|
||||||
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
|
|
||||||
}
|
|
||||||
out, reclaimed := pruneSupersededReadResults(messages)
|
|
||||||
if reclaimed != 0 {
|
|
||||||
t.Fatalf("unsafe results were pruned: %d", reclaimed)
|
|
||||||
}
|
|
||||||
for index := range messages {
|
|
||||||
if out[index].Content != messages[index].Content {
|
|
||||||
t.Fatalf("message %d changed unexpectedly", index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
|
|
||||||
bodyA := strings.Repeat("version a\n", 100)
|
|
||||||
bodyB := strings.Repeat("version b\n", 100)
|
|
||||||
messages := []zeroruntime.Message{
|
|
||||||
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
|
|
||||||
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
|
|
||||||
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
|
|
||||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
|
|
||||||
}
|
|
||||||
|
|
||||||
out, reclaimed := pruneSupersededReadResults(messages)
|
|
||||||
|
|
||||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
|
||||||
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
|
||||||
}
|
|
||||||
if out[3].Content != bodyB || out[5].Content != bodyA {
|
|
||||||
t.Fatal("changed middle result and newest matching result must remain exact")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||||
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,6 @@ func TestSpecialistDelegationContext(t *testing.T) {
|
|||||||
if strings.Contains(got, "nameless") {
|
if strings.Contains(got, "nameless") {
|
||||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
|
|
||||||
if strings.Contains(got, unwanted) {
|
|
||||||
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/repomap"
|
"github.com/Gitlawb/zero/internal/repomap"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,22 +70,6 @@ const (
|
|||||||
// guidelines), and the safety confirmation policy. It is built once per run so
|
// guidelines), and the safety confirmation policy. It is built once per run so
|
||||||
// every turn shares one (cacheable) system turn.
|
// every turn shares one (cacheable) system turn.
|
||||||
func buildSystemPrompt(options Options) string {
|
func buildSystemPrompt(options Options) string {
|
||||||
return buildSystemPromptParts(options).prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemPromptParts retains both the exact joined prompt and the diagnostic
|
|
||||||
// sections used by prompt-prefix tracing. Building these together prevents the
|
|
||||||
// tracing path from re-reading workspace state and guarantees that the hash is
|
|
||||||
// computed from the same prompt bytes placed in the request.
|
|
||||||
type systemPromptParts struct {
|
|
||||||
prompt string
|
|
||||||
baseInstructions string
|
|
||||||
confirmationPolicy string
|
|
||||||
projectContext string
|
|
||||||
skills string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSystemPromptParts(options Options) systemPromptParts {
|
|
||||||
core := strings.TrimSpace(options.SystemPrompt)
|
core := strings.TrimSpace(options.SystemPrompt)
|
||||||
if core == "" {
|
if core == "" {
|
||||||
core = strings.TrimSpace(coreSystemPrompt)
|
core = strings.TrimSpace(coreSystemPrompt)
|
||||||
@@ -95,9 +78,6 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
|||||||
core = fallbackSystemPrompt
|
core = fallbackSystemPrompt
|
||||||
}
|
}
|
||||||
sections := []string{core}
|
sections := []string{core}
|
||||||
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
|
|
||||||
sections = append(sections, transient)
|
|
||||||
}
|
|
||||||
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
||||||
sections = append(sections, addendum)
|
sections = append(sections, addendum)
|
||||||
}
|
}
|
||||||
@@ -117,34 +97,22 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
|||||||
if user := userGuidelines(); user != "" {
|
if user := userGuidelines(); user != "" {
|
||||||
sections = append(sections, user)
|
sections = append(sections, user)
|
||||||
}
|
}
|
||||||
project := workspaceContext(options.Cwd)
|
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||||
if project != "" {
|
sections = append(sections, ws)
|
||||||
sections = append(sections, project)
|
|
||||||
}
|
|
||||||
if modeCtx := permissionModeContext(options); modeCtx != "" {
|
|
||||||
sections = append(sections, modeCtx)
|
|
||||||
}
|
}
|
||||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||||
sections = append(sections, delegation)
|
sections = append(sections, delegation)
|
||||||
}
|
}
|
||||||
skillsBlock := skillsContext(options)
|
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||||
if skillsBlock != "" {
|
|
||||||
sections = append(sections, skillsBlock)
|
sections = append(sections, skillsBlock)
|
||||||
}
|
}
|
||||||
if style := responseStyleContext(options); style != "" {
|
if style := responseStyleContext(options); style != "" {
|
||||||
sections = append(sections, style)
|
sections = append(sections, style)
|
||||||
}
|
}
|
||||||
policy := strings.TrimSpace(confirmationPolicy)
|
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||||
if policy != "" {
|
|
||||||
sections = append(sections, policy)
|
sections = append(sections, policy)
|
||||||
}
|
}
|
||||||
return systemPromptParts{
|
return strings.Join(sections, "\n\n")
|
||||||
prompt: strings.Join(sections, "\n\n"),
|
|
||||||
baseInstructions: core,
|
|
||||||
confirmationPolicy: policy,
|
|
||||||
projectContext: project,
|
|
||||||
skills: skillsBlock,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||||
@@ -185,19 +153,21 @@ func approvedCommandPrefixContext(options Options) string {
|
|||||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// specialistDelegationContext describes when a specialist earns its extra
|
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||||
// context and coordination cost. It renders only when specialists are known
|
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||||
// (which is only where the Task tool is actually registered), so a run with no
|
// tool outputs out of the main context. It renders only when specialists are
|
||||||
// delegatable specialists produces the previous prompt unchanged.
|
// known (which is only where the Task tool is actually registered), so a run with
|
||||||
|
// no delegatable specialists produces the previous prompt unchanged.
|
||||||
func specialistDelegationContext(options Options) string {
|
func specialistDelegationContext(options Options) string {
|
||||||
if len(options.Specialists) == 0 {
|
if len(options.Specialists) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("<specialists>\n")
|
b.WriteString("<specialists>\n")
|
||||||
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
|
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||||
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
|
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||||
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
|
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||||
|
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||||
for _, info := range options.Specialists {
|
for _, info := range options.Specialists {
|
||||||
name := strings.TrimSpace(info.Name)
|
name := strings.TrimSpace(info.Name)
|
||||||
@@ -315,7 +285,11 @@ func workspaceContext(cwd string) string {
|
|||||||
b.WriteString("<environment>\n")
|
b.WriteString("<environment>\n")
|
||||||
b.WriteString("Working directory: " + cwd + "\n")
|
b.WriteString("Working directory: " + cwd + "\n")
|
||||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||||
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
if runtime.GOOS == "windows" {
|
||||||
|
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||||
|
}
|
||||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||||
b.WriteString("Git branch: " + branch + "\n")
|
b.WriteString("Git branch: " + branch + "\n")
|
||||||
}
|
}
|
||||||
@@ -685,12 +659,3 @@ func gitBranchForPrompt(cwd string) string {
|
|||||||
}
|
}
|
||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionModeContext(options Options) string {
|
|
||||||
switch options.PermissionMode {
|
|
||||||
case PermissionModePlan:
|
|
||||||
return "Plan mode is active on this session. Your role is read-only exploration and planning: inspect the workspace and shape the plan with update_plan, but do not make changes to files or execute commands."
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,10 +30,16 @@ work.
|
|||||||
read-before-edit discipline: inspect the target file and nearby callers,
|
read-before-edit discipline: inspect the target file and nearby callers,
|
||||||
tests, or config before you modify behavior. Never edit a file you have not
|
tests, or config before you modify behavior. Never edit a file you have not
|
||||||
read.
|
read.
|
||||||
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||||
work. Skip it for bounded changes in one component. Update only when the
|
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||||
overall phase changes, keep at most one item in_progress, and never create a
|
after EACH concrete unit of work (every file written, every command run), not
|
||||||
plan after the work is complete.
|
just at coarse milestones: mark the finished item completed and the next one
|
||||||
|
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||||
|
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||||
|
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||||
|
never clutter the conversation. Keep at most one item in_progress, and never
|
||||||
|
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||||
|
tasks.
|
||||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||||
naming, and conventions. Prefer the smallest change that fully solves the
|
naming, and conventions. Prefer the smallest change that fully solves the
|
||||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||||
@@ -48,49 +54,33 @@ work.
|
|||||||
## Editing discipline
|
## Editing discipline
|
||||||
|
|
||||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
|
||||||
edit_file, apply_patch, write_file - over shelling out to
|
apply_patch - over shelling out to cat/sed/awk/python for file operations.
|
||||||
cat/sed/awk/python for file operations.
|
|
||||||
They are safer, reviewable, and produce clean diffs.
|
They are safer, reviewable, and produce clean diffs.
|
||||||
- Use read_minified_file only to explore large or unfamiliar source. Use
|
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||||
read_file for small or likely edit targets and exact text, comments, or line
|
shell or script invocation.
|
||||||
numbers. Do not read both unless a new need for exact content appears.
|
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||||
- Keep edits focused and reviewable. A single patch may update several related
|
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||||
files when they form one coherent change; do not hide unrelated edits in a
|
file's comment density: do not add explanatory comments unless the user asks or
|
||||||
bulk shell or script rewrite.
|
the code is already comment-dense.
|
||||||
- For edits to existing files, use edit_file for a targeted change (old_string
|
|
||||||
must match the file exactly and be unique, so include a few surrounding
|
|
||||||
lines) and apply_patch when one coherent change spans several hunks or
|
|
||||||
files. Use write_file only to create a file or when most of it changes; do
|
|
||||||
not rewrite a whole file to change a few lines. Match the existing
|
|
||||||
indentation, imports, and idioms. Match the file's comment density: do not
|
|
||||||
add explanatory comments unless the user asks or the code is already
|
|
||||||
comment-dense.
|
|
||||||
- A successful edit result already confirms the change; do not re-read a file
|
|
||||||
just to verify an edit that succeeded. If an edit fails, read the error, fix
|
|
||||||
the old_string or hunk, and retry the same tool rather than switching to a
|
|
||||||
full rewrite.
|
|
||||||
- Solve the problem as posed, not a more general version of it. Add no
|
- Solve the problem as posed, not a more general version of it. Add no
|
||||||
speculative abstraction, configurability, or handling for cases that cannot
|
speculative abstraction, configurability, or handling for cases that cannot
|
||||||
occur, and nothing the user did not ask for. A small diff can still be
|
occur, and nothing the user did not ask for. A small diff can still be
|
||||||
over-built; if a 200-line solution could be 50, rewrite it. Once the required
|
over-built; if a 200-line solution could be 50, rewrite it.
|
||||||
behavior passes validation, stop instead of adding optional cleanup.
|
|
||||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||||
you did not author unless the task requires it; if you must, say so.
|
you did not author unless the task requires it; if you must, say so.
|
||||||
|
|
||||||
## Testing gate (mandatory)
|
## Testing gate (mandatory)
|
||||||
|
|
||||||
- After code changes, run the project's documented appropriate validators.
|
- After any change to code, verify after edits by running the project's
|
||||||
Scope iteration checks, combine compatible commands when diagnostics stay
|
validators before you summarize or commit: tests, type-checks, linters, and/or
|
||||||
useful, and run the final set once after the last edit. Rerun only after a
|
the build, as appropriate. Scope them to the change while iterating; reserve
|
||||||
change or failure.
|
full-suite runs for milestones.
|
||||||
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
- If you are unsure which validators apply, search the repo (Makefile, package
|
||||||
do not invent a parallel flow.
|
manifests, CI config) to find them.
|
||||||
- Never claim a task is done, and never commit, while validators are failing. If
|
- Never claim a task is done, and never commit, while validators are failing. If
|
||||||
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
||||||
a validator, say so explicitly rather than implying success.
|
a validator, say so explicitly rather than implying success.
|
||||||
- A successful native patch confirms the edit. Reread only when the next change
|
|
||||||
needs exact content or the result was ambiguous.
|
|
||||||
|
|
||||||
## Tool use
|
## Tool use
|
||||||
|
|
||||||
@@ -110,9 +100,6 @@ work.
|
|||||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||||
you need to clean up a running foreground command yourself, use write_stdin.
|
you need to clean up a running foreground command yourself, use write_stdin.
|
||||||
- write_stdin's session_id is only ever an id returned by a still-running
|
|
||||||
exec_command; never guess or probe ids. If you have no such session, start one
|
|
||||||
with exec_command, or use edit_file/apply_patch/write_file for file changes.
|
|
||||||
- write_stdin with empty input polls an existing exec_command session, and
|
- write_stdin with empty input polls an existing exec_command session, and
|
||||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||||
it can drive the running process beyond the original command. Non-tty sessions
|
it can drive the running process beyond the original command. Non-tty sessions
|
||||||
@@ -148,7 +135,6 @@ work.
|
|||||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
||||||
short snippets. Reference code with file paths and include line numbers when
|
short snippets. Reference code as `file:line` so it is clickable.
|
||||||
already known; do not look them up solely for the final summary.
|
|
||||||
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
||||||
say so; when something is done and verified, state it plainly without hedging.
|
say so; when something is done and verified, state it plainly without hedging.
|
||||||
@@ -60,11 +60,8 @@ const openAIPromptAddendum = `<model_guidance>
|
|||||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||||
commands, and symbols.
|
commands, and symbols.
|
||||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
|
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||||
file work. Use them directly; never run apply_patch through exec_command.
|
file work. Make one tool call per file; do not batch file writes into a script.
|
||||||
read_file, edit_file and write_file take one file per call, while one coherent
|
|
||||||
apply_patch may span several files. Keep independent native calls in the same
|
|
||||||
turn, and do not batch file writes into a script.
|
|
||||||
- Persist until the task is fully handled this turn: gather context, implement,
|
- Persist until the task is fully handled this turn: gather context, implement,
|
||||||
run the validators, and report — do not stop at a partial result.
|
run the validators, and report — do not stop at a partial result.
|
||||||
</model_guidance>`
|
</model_guidance>`
|
||||||
@@ -74,6 +71,5 @@ const geminiPromptAddendum = `<model_guidance>
|
|||||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||||
- Be concise and concrete. When you run a shell command with side effects, state
|
- Be concise and concrete. When you run a shell command with side effects, state
|
||||||
in one short clause why it is needed.
|
in one short clause why it is needed.
|
||||||
- Use update_plan only for work with at least three meaningful dependent steps;
|
- Use update_plan for any multi-step task and keep it current.
|
||||||
skip it for simple lookups and never create one after the work is complete.
|
|
||||||
</model_guidance>`
|
</model_guidance>`
|
||||||
@@ -29,9 +29,6 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
|||||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||||
}
|
}
|
||||||
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
|
|
||||||
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
|
|
||||||
}
|
|
||||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||||
// comment discipline is universal; it must not pick up another family's block.
|
// comment discipline is universal; it must not pick up another family's block.
|
||||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||||
|
|||||||
@@ -29,17 +29,14 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||||
if strings.Contains(prompt, "clickable file paths") {
|
|
||||||
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"read-before-edit",
|
"read-before-edit",
|
||||||
"inspect the target file",
|
"inspect the target file",
|
||||||
"plan then act",
|
"plan then act",
|
||||||
"choose the narrowest tool",
|
"choose the narrowest tool",
|
||||||
"for edits to existing files, use edit_file",
|
"prefer edit_file or apply_patch",
|
||||||
"verify after edits",
|
"verify after edits",
|
||||||
"honor the active permission mode",
|
"honor the active permission mode",
|
||||||
"avoid broad refactors",
|
"avoid broad refactors",
|
||||||
@@ -47,13 +44,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
|||||||
"do not recognize",
|
"do not recognize",
|
||||||
"scaled to the work",
|
"scaled to the work",
|
||||||
"comment density",
|
"comment density",
|
||||||
"skip it for bounded changes in one component",
|
|
||||||
"do not read both unless a new need for exact content appears",
|
|
||||||
"combine compatible commands when diagnostics stay useful",
|
|
||||||
"do not invent a parallel flow",
|
|
||||||
"a successful native patch confirms the edit",
|
|
||||||
"once the required behavior passes validation, stop",
|
|
||||||
"do not look them up solely for the final summary",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(prompt, want) {
|
if !strings.Contains(prompt, want) {
|
||||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||||
@@ -61,16 +51,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTransientSystemPromptIsIncludedOnlyWhenProvided(t *testing.T) {
|
|
||||||
const transient = "Handle this peer request under the current session policy."
|
|
||||||
if prompt := buildSystemPrompt(Options{}); strings.Contains(prompt, transient) {
|
|
||||||
t.Fatal("ordinary system prompt unexpectedly contains transient instructions")
|
|
||||||
}
|
|
||||||
if prompt := buildSystemPrompt(Options{TransientSystemPrompt: transient}); !strings.Contains(prompt, transient) {
|
|
||||||
t.Fatalf("transient instructions missing from system prompt:\n%s", prompt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
|||||||
@@ -1,566 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/trace"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
type taskStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
taskStatusActive taskStatus = "active"
|
|
||||||
taskStatusComplete taskStatus = "complete"
|
|
||||||
taskStatusIncomplete taskStatus = "incomplete"
|
|
||||||
)
|
|
||||||
|
|
||||||
type taskPlanParity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
taskPlanParityUnknown taskPlanParity = "unknown"
|
|
||||||
taskPlanParityMatch taskPlanParity = "match"
|
|
||||||
taskPlanParityMismatch taskPlanParity = "mismatch"
|
|
||||||
)
|
|
||||||
|
|
||||||
type taskPlanItem struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Notes string `json:"notes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskPlanState struct {
|
|
||||||
Items []taskPlanItem `json:"items,omitempty"`
|
|
||||||
Pending int `json:"pending"`
|
|
||||||
InProgress int `json:"in_progress"`
|
|
||||||
Completed int `json:"completed"`
|
|
||||||
Failed int `json:"failed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskToolState struct {
|
|
||||||
Succeeded int `json:"succeeded"`
|
|
||||||
Failed int `json:"failed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskVerificationState struct {
|
|
||||||
Passed int `json:"passed"`
|
|
||||||
Failed int `json:"failed"`
|
|
||||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskFailureState struct {
|
|
||||||
Key string `json:"key,omitempty"`
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Command string `json:"command,omitempty"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskApprovalState struct {
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Decision PermissionDecisionAction `json:"decision,omitempty"`
|
|
||||||
Scope string `json:"scope,omitempty"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskArtifactState struct {
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskStateSnapshot struct {
|
|
||||||
Revision int `json:"revision"`
|
|
||||||
Objective string `json:"objective"`
|
|
||||||
Status taskStatus `json:"status"`
|
|
||||||
Plan taskPlanState `json:"plan"`
|
|
||||||
Tools taskToolState `json:"tools"`
|
|
||||||
Verification taskVerificationState `json:"verification"`
|
|
||||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
|
||||||
Constraints []string `json:"constraints,omitempty"`
|
|
||||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
|
||||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
|
||||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
|
||||||
ResolvedFailureKeys []string `json:"-"`
|
|
||||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
|
||||||
CompletionReason string `json:"completion_reason,omitempty"`
|
|
||||||
PlanParity taskPlanParity `json:"plan_parity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type taskStateEventKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
taskStateEventPlan taskStateEventKind = iota + 1
|
|
||||||
taskStateEventToolResult
|
|
||||||
taskStateEventVerification
|
|
||||||
taskStateEventCompletion
|
|
||||||
taskStateEventPermission
|
|
||||||
)
|
|
||||||
|
|
||||||
type taskStateEvent struct {
|
|
||||||
kind taskStateEventKind
|
|
||||||
arguments string
|
|
||||||
toolResult ToolResult
|
|
||||||
verification Outcome
|
|
||||||
completion completionEvaluation
|
|
||||||
permission PermissionEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// taskState is a deterministic projection of facts the loop already observes.
|
|
||||||
// It does not initiate work or replace the transcript; consumers must check
|
|
||||||
// transcript parity before using its compact representation.
|
|
||||||
type taskState struct {
|
|
||||||
snapshotValue taskStateSnapshot
|
|
||||||
changedFiles map[string]struct{}
|
|
||||||
recorder *trace.Recorder
|
|
||||||
planObserved bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
|
||||||
state := &taskState{
|
|
||||||
snapshotValue: taskStateSnapshot{
|
|
||||||
Objective: strings.TrimSpace(objective),
|
|
||||||
Status: taskStatusActive,
|
|
||||||
PlanParity: taskPlanParityUnknown,
|
|
||||||
},
|
|
||||||
changedFiles: map[string]struct{}{},
|
|
||||||
}
|
|
||||||
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
|
||||||
state.recorder = recorder
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) observe(event taskStateEvent) bool {
|
|
||||||
if state == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
changed := false
|
|
||||||
switch event.kind {
|
|
||||||
case taskStateEventPlan:
|
|
||||||
plan, ok := parseTaskPlan(event.arguments)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
state.snapshotValue.Plan = summarizeTaskPlan(plan)
|
|
||||||
state.planObserved = true
|
|
||||||
state.markActive()
|
|
||||||
changed = true
|
|
||||||
case taskStateEventToolResult:
|
|
||||||
state.markActive()
|
|
||||||
if event.toolResult.Status == tools.StatusOK {
|
|
||||||
state.snapshotValue.Tools.Succeeded++
|
|
||||||
} else {
|
|
||||||
state.snapshotValue.Tools.Failed++
|
|
||||||
}
|
|
||||||
for _, path := range event.toolResult.ChangedFiles {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path != "" {
|
|
||||||
state.changedFiles[path] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
|
||||||
state.observeToolEvidence(event.arguments, event.toolResult)
|
|
||||||
changed = true
|
|
||||||
case taskStateEventVerification:
|
|
||||||
if event.verification == OutcomeDisabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
state.markActive()
|
|
||||||
state.snapshotValue.Verification.LastOutcome = event.verification
|
|
||||||
switch event.verification {
|
|
||||||
case OutcomePassed:
|
|
||||||
state.snapshotValue.Verification.Passed++
|
|
||||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
|
||||||
state.snapshotValue.Verification.Failed++
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
case taskStateEventCompletion:
|
|
||||||
state.snapshotValue.CompletionDecision = event.completion.Decision
|
|
||||||
state.snapshotValue.CompletionReason = event.completion.Reason
|
|
||||||
switch event.completion.Decision {
|
|
||||||
case CompletionComplete:
|
|
||||||
state.snapshotValue.Status = taskStatusComplete
|
|
||||||
case CompletionIncomplete:
|
|
||||||
state.snapshotValue.Status = taskStatusIncomplete
|
|
||||||
default:
|
|
||||||
state.snapshotValue.Status = taskStatusActive
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
case taskStateEventPermission:
|
|
||||||
state.markActive()
|
|
||||||
state.observePermission(event.permission)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
state.snapshotValue.Revision++
|
|
||||||
if event.kind != taskStateEventToolResult {
|
|
||||||
state.emit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) markActive() {
|
|
||||||
state.snapshotValue.Status = taskStatusActive
|
|
||||||
state.snapshotValue.CompletionDecision = ""
|
|
||||||
state.snapshotValue.CompletionReason = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) snapshot() taskStateSnapshot {
|
|
||||||
if state == nil {
|
|
||||||
return taskStateSnapshot{}
|
|
||||||
}
|
|
||||||
snapshot := state.snapshotValue
|
|
||||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
|
||||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
|
||||||
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
|
|
||||||
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
|
|
||||||
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
|
|
||||||
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
|
|
||||||
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxTaskEvidenceEntries = 8
|
|
||||||
maxTaskEvidenceBytes = 320
|
|
||||||
maxTaskConstraints = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
|
|
||||||
command := capTaskEvidence(commandFromArguments(arguments))
|
|
||||||
key := taskFailureKey(result.Name, command, arguments)
|
|
||||||
if result.Status == tools.StatusOK {
|
|
||||||
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
|
|
||||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
|
||||||
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
|
|
||||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
|
||||||
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
|
|
||||||
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
|
|
||||||
}, maxTaskEvidenceEntries)
|
|
||||||
}
|
|
||||||
for _, metaKey := range []string{"spill_path", "artifact_path"} {
|
|
||||||
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
|
|
||||||
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
|
|
||||||
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func taskFailureKey(toolName, command, arguments string) string {
|
|
||||||
identity := command
|
|
||||||
if identity == "" {
|
|
||||||
canonical := []byte(strings.TrimSpace(arguments))
|
|
||||||
var value any
|
|
||||||
if json.Unmarshal(canonical, &value) == nil {
|
|
||||||
if encoded, err := json.Marshal(value); err == nil {
|
|
||||||
canonical = encoded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
digest := sha256.Sum256(canonical)
|
|
||||||
identity = fmt.Sprintf("args:%x", digest[:8])
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(toolName) + "\x00" + identity
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) observePermission(event PermissionEvent) {
|
|
||||||
decision := event.DecisionAction
|
|
||||||
if decision == "" {
|
|
||||||
decision = PermissionDecisionAction(event.Action)
|
|
||||||
}
|
|
||||||
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
|
|
||||||
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
|
|
||||||
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
|
|
||||||
}, maxTaskEvidenceEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandFromArguments(arguments string) string {
|
|
||||||
var object map[string]json.RawMessage
|
|
||||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, key := range []string{"cmd", "command", "script", "shell"} {
|
|
||||||
var command string
|
|
||||||
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
|
|
||||||
return strings.TrimSpace(command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstLine(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if index := strings.IndexByte(value, '\n'); index >= 0 {
|
|
||||||
value = value[:index]
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func capTaskEvidence(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if len(value) <= maxTaskEvidenceBytes {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
limit := maxTaskEvidenceBytes - 3
|
|
||||||
for limit > 0 && !utf8.RuneStart(value[limit]) {
|
|
||||||
limit--
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(value[:limit]) + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
var explicitConstraintPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
|
|
||||||
regexp.MustCompile(`(?i)\bdon'?t want\b`),
|
|
||||||
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
|
|
||||||
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
|
|
||||||
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
|
|
||||||
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractExplicitConstraints(text string) []string {
|
|
||||||
var constraints []string
|
|
||||||
for _, line := range strings.Split(text, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pattern := range explicitConstraintPatterns {
|
|
||||||
if pattern.MatchString(line) {
|
|
||||||
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(constraints) == maxTaskConstraints {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return constraints
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendBounded[T any](values []T, value T, limit int) []T {
|
|
||||||
values = append(values, value)
|
|
||||||
if len(values) > limit {
|
|
||||||
values = values[len(values)-limit:]
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
|
|
||||||
filtered := values[:0]
|
|
||||||
for _, existing := range values {
|
|
||||||
if existing != value {
|
|
||||||
filtered = append(filtered, existing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return appendBounded(filtered, value, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasTaskFailure(values []taskFailureState, key string) bool {
|
|
||||||
for _, value := range values {
|
|
||||||
if value.Key == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
|
|
||||||
out := values[:0]
|
|
||||||
for _, value := range values {
|
|
||||||
if value.Key != key {
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeString(values []string, target string) []string {
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
if value != target {
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// observePlanParity compares only the latest plan projection with the plan tool
|
|
||||||
// calls still present in messages. It mutates the snapshot and emits when the
|
|
||||||
// parity value changes; objective, tool, and verification fields are not part of
|
|
||||||
// this comparison.
|
|
||||||
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
|
|
||||||
if state == nil {
|
|
||||||
return taskPlanParityUnknown
|
|
||||||
}
|
|
||||||
transcriptPlan, found, valid := latestTaskPlan(messages)
|
|
||||||
tracked := state.snapshotValue.Plan.Items
|
|
||||||
parity := taskPlanParityMatch
|
|
||||||
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
|
|
||||||
parity = taskPlanParityMismatch
|
|
||||||
}
|
|
||||||
if state.snapshotValue.PlanParity != parity {
|
|
||||||
state.snapshotValue.PlanParity = parity
|
|
||||||
state.snapshotValue.Revision++
|
|
||||||
state.emit()
|
|
||||||
}
|
|
||||||
return parity
|
|
||||||
}
|
|
||||||
|
|
||||||
type completionContext struct {
|
|
||||||
Objective string
|
|
||||||
PlanPending bool
|
|
||||||
PlanMatchesTranscript bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
|
|
||||||
context := completionContext{PlanPending: transcriptPlanPending}
|
|
||||||
if state == nil {
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
context.Objective = state.snapshotValue.Objective
|
|
||||||
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
|
|
||||||
if context.PlanMatchesTranscript {
|
|
||||||
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
|
|
||||||
if state == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
state.observePlanParity(messages)
|
|
||||||
snapshot := state.snapshot()
|
|
||||||
return &snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *taskState) emit() {
|
|
||||||
if state == nil || state.recorder == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapshot := state.snapshotValue
|
|
||||||
state.recorder.EmitTaskState(trace.TaskStateEvent{
|
|
||||||
Revision: snapshot.Revision,
|
|
||||||
Status: string(snapshot.Status),
|
|
||||||
PlanPending: snapshot.Plan.Pending,
|
|
||||||
PlanInProgress: snapshot.Plan.InProgress,
|
|
||||||
PlanCompleted: snapshot.Plan.Completed,
|
|
||||||
PlanFailed: snapshot.Plan.Failed,
|
|
||||||
ToolsSucceeded: snapshot.Tools.Succeeded,
|
|
||||||
ToolsFailed: snapshot.Tools.Failed,
|
|
||||||
VerificationPassed: snapshot.Verification.Passed,
|
|
||||||
VerificationFailed: snapshot.Verification.Failed,
|
|
||||||
VerificationOutcome: string(snapshot.Verification.LastOutcome),
|
|
||||||
ChangedFileCount: len(snapshot.ChangedFiles),
|
|
||||||
CompletionDecision: string(snapshot.CompletionDecision),
|
|
||||||
PlanParity: string(snapshot.PlanParity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
|
|
||||||
var object map[string]json.RawMessage
|
|
||||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
rawPlan, ok := object["plan"]
|
|
||||||
if !ok || string(rawPlan) == "null" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
var parsed []struct {
|
|
||||||
ID *string `json:"id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Notes string `json:"notes"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(rawPlan, &parsed) != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
plan := make([]taskPlanItem, 0, len(parsed))
|
|
||||||
for _, raw := range parsed {
|
|
||||||
if raw.Content == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
plan = append(plan, taskPlanItem{
|
|
||||||
Content: raw.Content,
|
|
||||||
Status: normalizeTaskPlanStatus(raw.Status),
|
|
||||||
Notes: raw.Notes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
lastInProgress := -1
|
|
||||||
for index := range plan {
|
|
||||||
if plan[index].Status == "in_progress" {
|
|
||||||
if lastInProgress >= 0 {
|
|
||||||
plan[lastInProgress].Status = "completed"
|
|
||||||
}
|
|
||||||
lastInProgress = index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(plan) == 0 {
|
|
||||||
return nil, true
|
|
||||||
}
|
|
||||||
return plan, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTaskPlanStatus(status string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
||||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
|
|
||||||
return "completed"
|
|
||||||
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
|
|
||||||
return "in_progress"
|
|
||||||
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
|
||||||
return "failed"
|
|
||||||
default:
|
|
||||||
return "pending"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
|
|
||||||
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
|
|
||||||
for _, item := range items {
|
|
||||||
switch item.Status {
|
|
||||||
case "completed":
|
|
||||||
plan.Completed++
|
|
||||||
case "in_progress":
|
|
||||||
plan.InProgress++
|
|
||||||
case "failed":
|
|
||||||
plan.Failed++
|
|
||||||
default:
|
|
||||||
plan.Pending++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan
|
|
||||||
}
|
|
||||||
|
|
||||||
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
|
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
|
||||||
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
|
|
||||||
call := messages[i].ToolCalls[j]
|
|
||||||
if call.Name != planToolName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plan, ok := parseTaskPlan(call.Arguments)
|
|
||||||
return plan, true, ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedKeys(values map[string]struct{}) []string {
|
|
||||||
keys := make([]string, 0, len(values))
|
|
||||||
for value := range values {
|
|
||||||
keys = append(keys, value)
|
|
||||||
}
|
|
||||||
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
|
|
||||||
// deterministic even if events originate from parallel read batches later.
|
|
||||||
sort.Strings(keys)
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/trace"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
|
||||||
events := []taskStateEvent{
|
|
||||||
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
|
||||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
|
||||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
|
||||||
{kind: taskStateEventVerification, verification: OutcomePassed},
|
|
||||||
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
first := newTaskState("ship the change", nil)
|
|
||||||
second := newTaskState("ship the change", nil)
|
|
||||||
for _, event := range events {
|
|
||||||
first.observe(event)
|
|
||||||
second.observe(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
|
||||||
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
|
||||||
}
|
|
||||||
got := first.snapshot()
|
|
||||||
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
|
||||||
t.Fatalf("unexpected plan snapshot: %#v", got)
|
|
||||||
}
|
|
||||||
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
|
||||||
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
|
||||||
}
|
|
||||||
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
|
||||||
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
|
||||||
recorder := trace.NewRecorder("session", "run", "")
|
|
||||||
state := newTaskState("objective", recorder)
|
|
||||||
for range 20 {
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
|
||||||
}
|
|
||||||
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
|
||||||
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder = trace.NewRecorder("session", "run-2", "")
|
|
||||||
state = newTaskState("objective", recorder)
|
|
||||||
for range 20 {
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
|
||||||
}
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
|
||||||
events := recorder.Finish().TaskStates
|
|
||||||
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
|
||||||
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
|
||||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
||||||
{
|
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
recorder := trace.NewRecorder("session", "run", "")
|
|
||||||
|
|
||||||
result, err := Run(context.Background(), "implement the change", provider, Options{
|
|
||||||
Registry: registry,
|
|
||||||
Trace: recorder,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
if result.FinalAnswer != "done" {
|
|
||||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
|
||||||
}
|
|
||||||
events := recorder.Finish().TaskStates
|
|
||||||
if len(events) < 3 {
|
|
||||||
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
|
||||||
}
|
|
||||||
last := events[len(events)-1]
|
|
||||||
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
|
||||||
t.Fatalf("unexpected final task snapshot: %#v", last)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
|
||||||
state := newTaskState("objective", nil)
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
|
||||||
|
|
||||||
snapshot := state.snapshot()
|
|
||||||
snapshot.Plan.Items[0].Content = "mutated"
|
|
||||||
snapshot.ChangedFiles[0] = "mutated.go"
|
|
||||||
|
|
||||||
fresh := state.snapshot()
|
|
||||||
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
|
||||||
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
|
|
||||||
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
|
|
||||||
arguments := `{"cmd":"go test ./internal/agent"}`
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
|
||||||
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
|
|
||||||
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
|
|
||||||
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
|
|
||||||
}})
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
|
||||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
|
|
||||||
Scope: "/tmp", DecisionReason: "write a temporary fixture",
|
|
||||||
}})
|
|
||||||
|
|
||||||
snapshot := state.snapshot()
|
|
||||||
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
|
|
||||||
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
|
|
||||||
}
|
|
||||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
|
|
||||||
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
|
|
||||||
}
|
|
||||||
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
|
|
||||||
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
|
|
||||||
}
|
|
||||||
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
|
|
||||||
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
|
|
||||||
}
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
|
||||||
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
|
|
||||||
}})
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
|
|
||||||
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
|
|
||||||
}})
|
|
||||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
|
|
||||||
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
|
||||||
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
|
|
||||||
}})
|
|
||||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
|
|
||||||
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
|
|
||||||
}
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
|
||||||
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
|
|
||||||
}})
|
|
||||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
|
|
||||||
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
|
|
||||||
}
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
|
||||||
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
|
|
||||||
}})
|
|
||||||
snapshot = state.snapshot()
|
|
||||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
|
|
||||||
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
|
|
||||||
}
|
|
||||||
for _, key := range snapshot.ResolvedFailureKeys {
|
|
||||||
if key == snapshot.UnresolvedFailures[0].Key {
|
|
||||||
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
|
||||||
state := newTaskState("objective", nil)
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
|
||||||
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
||||||
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
|
||||||
}}}
|
|
||||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
|
||||||
t.Fatalf("parity = %q, want match", parity)
|
|
||||||
}
|
|
||||||
|
|
||||||
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
|
||||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
|
||||||
t.Fatalf("parity = %q, want mismatch", parity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
|
||||||
state := newTaskState("objective", nil)
|
|
||||||
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
|
||||||
|
|
||||||
snapshot := state.snapshot()
|
|
||||||
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
|
||||||
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
|
||||||
}
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
|
||||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
|
||||||
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
|
||||||
}
|
|
||||||
|
|
||||||
empty := newTaskState("objective", nil)
|
|
||||||
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
|
||||||
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
|
||||||
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
|
||||||
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
|
||||||
state := newTaskState("objective", nil)
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
|
||||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
||||||
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
|
||||||
}}}
|
|
||||||
|
|
||||||
context := state.completionContext(messages, true)
|
|
||||||
if !context.PlanPending || context.PlanMatchesTranscript {
|
|
||||||
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
|
||||||
}
|
|
||||||
if context.Objective != "objective" {
|
|
||||||
t.Fatalf("objective lost from completion context: %#v", context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
|
||||||
state := newTaskState("objective", nil)
|
|
||||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
|
||||||
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
|
||||||
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
|
||||||
}}}
|
|
||||||
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
|
||||||
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
mismatching := append([]zeroruntime.Message(nil), matching...)
|
|
||||||
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
|
||||||
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
|
||||||
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
|
||||||
for _, arguments := range []string{
|
|
||||||
`{}`,
|
|
||||||
`{"plan":null}`,
|
|
||||||
`{"plan":[{"step":"alias is not accepted"}]}`,
|
|
||||||
`{"plan":[{"content":""}]}`,
|
|
||||||
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
|
||||||
`{"plan":[{"id":4,"content":"valid"}]}`,
|
|
||||||
} {
|
|
||||||
if plan, ok := parseTaskPlan(arguments); ok {
|
|
||||||
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
|
||||||
// should be able to.
|
|
||||||
type imageTool struct {
|
|
||||||
media string
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (imageTool) Name() string { return "capture" }
|
|
||||||
func (imageTool) Description() string { return "Captures an image." }
|
|
||||||
func (imageTool) Parameters() tools.Schema {
|
|
||||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
|
||||||
}
|
|
||||||
func (imageTool) Safety() tools.Safety {
|
|
||||||
return tools.Safety{Permission: tools.PermissionAllow}
|
|
||||||
}
|
|
||||||
func (t imageTool) Run(context.Context, map[string]any) tools.Result {
|
|
||||||
return tools.Result{
|
|
||||||
Status: tools.StatusOK,
|
|
||||||
Output: "Captured a screenshot.",
|
|
||||||
Images: []zeroruntime.ImageBlock{{MediaType: t.media, Data: t.data}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tool that produces an image must get that image in front of the model.
|
|
||||||
//
|
|
||||||
// It cannot ride the tool-result message: every provider drops images there.
|
|
||||||
// Anthropic maps a tool result to a tool_result block with string content,
|
|
||||||
// Gemini to a functionResponse, and OpenAI guards its image parts to the user
|
|
||||||
// role explicitly. So the image has to arrive as a following user message,
|
|
||||||
// which is also the only shape that keeps one tool result per tool call.
|
|
||||||
func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
|
||||||
|
|
||||||
provider := &mockProvider{
|
|
||||||
turns: [][]zeroruntime.StreamEvent{
|
|
||||||
{
|
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
{Type: zeroruntime.StreamEventText, Content: "I can see it."},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
|
||||||
Registry: registry,
|
|
||||||
MaxTurns: 2,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var carrier *zeroruntime.Message
|
|
||||||
for index := range result.Messages {
|
|
||||||
if len(result.Messages[index].Images) > 0 {
|
|
||||||
carrier = &result.Messages[index]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if carrier == nil {
|
|
||||||
t.Fatal("no message carried the tool's image; the model never saw it")
|
|
||||||
}
|
|
||||||
if carrier.Role != zeroruntime.MessageRoleUser {
|
|
||||||
t.Errorf("image rode a %q message; every provider only serializes images on the user role", carrier.Role)
|
|
||||||
}
|
|
||||||
if got := carrier.Images[0].MediaType; got != "image/png" {
|
|
||||||
t.Errorf("media type = %q, want image/png", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
|
||||||
toolResults := 0
|
|
||||||
for _, message := range result.Messages {
|
|
||||||
if message.Role == zeroruntime.MessageRoleTool {
|
|
||||||
toolResults++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if toolResults != 1 {
|
|
||||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// textTool is a second, image-free tool so a turn can carry two calls.
|
|
||||||
type textTool struct{}
|
|
||||||
|
|
||||||
func (textTool) Name() string { return "note" }
|
|
||||||
func (textTool) Description() string { return "Notes something." }
|
|
||||||
func (textTool) Parameters() tools.Schema {
|
|
||||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
|
||||||
}
|
|
||||||
func (textTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
|
|
||||||
func (textTool) Run(context.Context, map[string]any) tools.Result {
|
|
||||||
return tools.Result{Status: tools.StatusOK, Output: "noted"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool results for one assistant turn must stay contiguous. The image messages
|
|
||||||
// are user-role, and a user message between two tool_results breaks strict
|
|
||||||
// provider replay: Anthropic coalesces consecutive user content into one block
|
|
||||||
// list and requires tool_result blocks first, so interleaving produces
|
|
||||||
// [tool_result, text, image, tool_result] and the request is rejected.
|
|
||||||
//
|
|
||||||
// The first version of this feature appended each image immediately after its
|
|
||||||
// own tool result, which is exactly that shape. A single-tool-call test passed
|
|
||||||
// and said nothing about it.
|
|
||||||
func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
|
||||||
registry := tools.NewRegistry()
|
|
||||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
|
||||||
registry.Register(textTool{})
|
|
||||||
|
|
||||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
||||||
{
|
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "capture"},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "note"},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c2", ArgumentsFragment: `{}`},
|
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
|
|
||||||
{Type: zeroruntime.StreamEventDone},
|
|
||||||
},
|
|
||||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
|
||||||
}}
|
|
||||||
|
|
||||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two separate ifs rather than a switch: a tool-role message that also
|
|
||||||
// carried images would match only the first arm of a switch, so a regression
|
|
||||||
// that put the image back on the tool result would read as "no image at all"
|
|
||||||
// instead of naming what actually happened.
|
|
||||||
type toolResult struct {
|
|
||||||
index int
|
|
||||||
id string
|
|
||||||
}
|
|
||||||
var toolResults []toolResult
|
|
||||||
imageIndex := -1
|
|
||||||
for index, message := range result.Messages {
|
|
||||||
if message.Role == zeroruntime.MessageRoleTool {
|
|
||||||
toolResults = append(toolResults, toolResult{index: index, id: message.ToolCallID})
|
|
||||||
}
|
|
||||||
if len(message.Images) > 0 {
|
|
||||||
if imageIndex >= 0 {
|
|
||||||
t.Fatalf("image delivered twice, at %d and %d; recorded %s", imageIndex, index, messageShape(result.Messages))
|
|
||||||
}
|
|
||||||
imageIndex = index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imageIndex < 0 {
|
|
||||||
t.Fatalf("the image never reached the model; recorded %s", messageShape(result.Messages))
|
|
||||||
}
|
|
||||||
if len(toolResults) != 2 {
|
|
||||||
t.Fatalf("got %d tool-result messages for 2 tool calls; recorded %s", len(toolResults), messageShape(result.Messages))
|
|
||||||
}
|
|
||||||
if toolResults[0].id != "c1" || toolResults[1].id != "c2" {
|
|
||||||
t.Errorf("tool results answered %q then %q, want c1 then c2", toolResults[0].id, toolResults[1].id)
|
|
||||||
}
|
|
||||||
// The contract Anthropic enforces: every tool_result block has to come before
|
|
||||||
// any other block in the user message it lands in. Anything wedged between
|
|
||||||
// the two results breaks that, which is the 400 this PR exists to avoid.
|
|
||||||
if toolResults[1].index != toolResults[0].index+1 {
|
|
||||||
t.Errorf("tool results at %d and %d are not adjacent; %q message at %d splits them; recorded %s",
|
|
||||||
toolResults[0].index, toolResults[1].index, result.Messages[toolResults[0].index+1].Role,
|
|
||||||
toolResults[0].index+1, messageShape(result.Messages))
|
|
||||||
}
|
|
||||||
// Follows the last tool result, deliberately NOT pinned to exactly last+1.
|
|
||||||
// appendUserBlocks coalesces consecutive user-role content, so
|
|
||||||
// [tool_result, tool_result, text, image] still maps to tool_result blocks
|
|
||||||
// first and replays fine; pinning would redden that valid shape for nothing.
|
|
||||||
if imageIndex <= toolResults[1].index {
|
|
||||||
t.Errorf("image message at %d does not follow the last tool result at %d; recorded %s",
|
|
||||||
imageIndex, toolResults[1].index, messageShape(result.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// messageShape renders the conversation as a compact role list so a failure says
|
|
||||||
// what the ordering actually was instead of dumping whole messages.
|
|
||||||
func messageShape(messages []zeroruntime.Message) string {
|
|
||||||
parts := make([]string, 0, len(messages))
|
|
||||||
for index, message := range messages {
|
|
||||||
part := fmt.Sprintf("%d:%s", index, message.Role)
|
|
||||||
if message.ToolCallID != "" {
|
|
||||||
part += "(" + message.ToolCallID + ")"
|
|
||||||
}
|
|
||||||
if len(message.Images) > 0 {
|
|
||||||
part += "+image"
|
|
||||||
}
|
|
||||||
parts = append(parts, part)
|
|
||||||
}
|
|
||||||
return "[" + strings.Join(parts, " ") + "]"
|
|
||||||
}
|
|
||||||
+17
-84
@@ -3,7 +3,6 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/execution"
|
|
||||||
"github.com/Gitlawb/zero/internal/hooks"
|
"github.com/Gitlawb/zero/internal/hooks"
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
"github.com/Gitlawb/zero/internal/streamjson"
|
"github.com/Gitlawb/zero/internal/streamjson"
|
||||||
@@ -26,15 +25,6 @@ const (
|
|||||||
PermissionModeAsk PermissionMode = "ask"
|
PermissionModeAsk PermissionMode = "ask"
|
||||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||||
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
||||||
// PermissionModePlan is an interactive, read-only planning mode. It applies
|
|
||||||
// to the CURRENT session (unlike spec-draft, which drafts in a separate
|
|
||||||
// session): the agent may inspect the workspace and shape the plan with
|
|
||||||
// update_plan/ask_user, but no mutating tool is advertised, so it cannot
|
|
||||||
// write files, run shell, or implement while planning. Entry points:
|
|
||||||
// the TUI's /plan on (exit with /plan off, which restores whatever mode
|
|
||||||
// was active before), `zero exec --plan`, and the ACP session mode
|
|
||||||
// selector ("plan").
|
|
||||||
PermissionModePlan PermissionMode = "plan"
|
|
||||||
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
||||||
// advertises the in-workspace mutators a member needs to build (write/edit +
|
// advertises the in-workspace mutators a member needs to build (write/edit +
|
||||||
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
||||||
@@ -77,19 +67,11 @@ type ToolResult struct {
|
|||||||
Output string
|
Output string
|
||||||
// Truncated reports that the tool's model-visible output omitted content.
|
// Truncated reports that the tool's model-visible output omitted content.
|
||||||
// The full result may be recoverable through Meta["spill_path"].
|
// The full result may be recoverable through Meta["spill_path"].
|
||||||
Truncated bool
|
Truncated bool
|
||||||
Meta map[string]string
|
Meta map[string]string
|
||||||
// Images the tool produced, delivered to the model as a following user
|
|
||||||
// message rather than on this result. See tools.Result.Images.
|
|
||||||
Images []zeroruntime.ImageBlock
|
|
||||||
Redacted bool
|
Redacted bool
|
||||||
ChangedFiles []string
|
ChangedFiles []string
|
||||||
FileDiffs []tools.FileDiff
|
Display tools.Display
|
||||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
|
||||||
// command execution; callers must not schedule per-file work from them.
|
|
||||||
ChangeSummaries []execution.Change
|
|
||||||
Display tools.Display
|
|
||||||
Outcome tools.ToolOutcome
|
|
||||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||||
DenialReason DenialCategory
|
DenialReason DenialCategory
|
||||||
@@ -110,24 +92,6 @@ type ToolResult struct {
|
|||||||
RequestedModel string
|
RequestedModel string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelOutput returns the bounded provider-facing result while preserving
|
|
||||||
// compatibility with synthetic and restored results created before outcomes
|
|
||||||
// were finalized.
|
|
||||||
func (result ToolResult) ModelOutput() string {
|
|
||||||
if result.Outcome.Finalized() {
|
|
||||||
return result.Outcome.ModelView
|
|
||||||
}
|
|
||||||
return result.Output
|
|
||||||
}
|
|
||||||
|
|
||||||
// HumanDisplay returns the presentation intended for interactive surfaces.
|
|
||||||
func (result ToolResult) HumanDisplay() tools.Display {
|
|
||||||
if result.Outcome.Finalized() {
|
|
||||||
return result.Outcome.HumanView
|
|
||||||
}
|
|
||||||
return result.Display
|
|
||||||
}
|
|
||||||
|
|
||||||
// DenialCategory classifies why a tool call was blocked before it executed.
|
// DenialCategory classifies why a tool call was blocked before it executed.
|
||||||
type DenialCategory string
|
type DenialCategory string
|
||||||
|
|
||||||
@@ -205,22 +169,6 @@ type PermissionRequest struct {
|
|||||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
||||||
// PrefixApprovalEscalates reports that approving a command prefix will also
|
|
||||||
// run the command OUTSIDE the sandbox, not merely stop asking about it.
|
|
||||||
//
|
|
||||||
// It exists because that consequence was real but invisible. Approving a
|
|
||||||
// prefix rewrites the call to sandbox_permissions: require_escalated, which
|
|
||||||
// resolves to a nil engine and genuinely unsandboxed execution, and the
|
|
||||||
// engine's own escalation prompt is then satisfied by the approval just
|
|
||||||
// given for the sandboxed form. So the operator authorized one thing and got
|
|
||||||
// a wider one, having been shown only "allow command prefix for session".
|
|
||||||
//
|
|
||||||
// The escalation itself is deliberate and guarded: proposedCommandPrefix
|
|
||||||
// refuses to offer a prefix while any other segment of the command is not
|
|
||||||
// known-safe, precisely so an unreviewed segment cannot ride out of the
|
|
||||||
// sandbox on it. What was missing was telling the person deciding, so this
|
|
||||||
// surfaces it rather than removing it.
|
|
||||||
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionDecision struct {
|
type PermissionDecision struct {
|
||||||
@@ -326,12 +274,8 @@ type Options struct {
|
|||||||
ProviderName string
|
ProviderName string
|
||||||
Model string
|
Model string
|
||||||
ReasoningEffort string
|
ReasoningEffort string
|
||||||
ServiceTier string
|
|
||||||
Cwd string
|
Cwd string
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
// TransientSystemPrompt adds trusted runtime guidance for this run only.
|
|
||||||
// Empty preserves the ordinary system prompt byte-for-byte.
|
|
||||||
TransientSystemPrompt string
|
|
||||||
// ResponseStyle is the operator-selected reply style from the TUI /style
|
// ResponseStyle is the operator-selected reply style from the TUI /style
|
||||||
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
||||||
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
||||||
@@ -349,26 +293,10 @@ type Options struct {
|
|||||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||||
CompactionPreserveLast int
|
CompactionPreserveLast int
|
||||||
// Summarizer, when set, lazily builds the provider used for compaction
|
Registry *tools.Registry
|
||||||
// summarization calls — typically a cheap/fast model, since summaries at
|
PermissionMode PermissionMode
|
||||||
// main-model prices are the single most expensive recurring event in a
|
Autonomy string
|
||||||
// long run. It receives the main model currently in force and may return
|
Sandbox *sandbox.Engine
|
||||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
|
||||||
// already the cheap one). Built on the first paid compaction and again
|
|
||||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
|
||||||
// the run's main provider until the next switch, so a misconfigured
|
|
||||||
// summarizer can never break compaction. nil keeps today's behavior.
|
|
||||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
|
||||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
|
||||||
// the compactor can re-derive its threshold after a mid-run model switch
|
|
||||||
// (escalate_model). Without it a switch keeps compacting against the
|
|
||||||
// original model's window — overflowing a smaller target or over-compacting
|
|
||||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
|
||||||
ContextWindowFor func(modelID string) int
|
|
||||||
Registry *tools.Registry
|
|
||||||
PermissionMode PermissionMode
|
|
||||||
Autonomy string
|
|
||||||
Sandbox *sandbox.Engine
|
|
||||||
// FileTracker records per-session file read/write versions so the write tools
|
// FileTracker records per-session file read/write versions so the write tools
|
||||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||||
// disables the check. Created once per session and threaded into every tool run.
|
// disables the check. Created once per session and threaded into every tool run.
|
||||||
@@ -398,11 +326,9 @@ type Options struct {
|
|||||||
// specialist child process emits while running. The toolCallID identifies
|
// specialist child process emits while running. The toolCallID identifies
|
||||||
// which Task tool call the progress belongs to. nil is a no-op.
|
// which Task tool call the progress belongs to. nil is a no-op.
|
||||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||||
// OnContext, when set, is called for each main agent request with its
|
// OnContext, when set, is called once per turn with the per-category context
|
||||||
// per-category context budget, including a replacement request after
|
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||||
// compaction or a stall retry. Internal summarizer requests are excluded so
|
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||||
// surfaces keep showing the active conversation budget. Opt-in like the other
|
|
||||||
// callbacks; nil is a no-op.
|
|
||||||
OnContext func(ContextBreakdown)
|
OnContext func(ContextBreakdown)
|
||||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
||||||
// mid-run: the loop calls it with the requested model id and, on success,
|
// mid-run: the loop calls it with the requested model id and, on success,
|
||||||
@@ -497,6 +423,13 @@ type Result struct {
|
|||||||
IncompleteReason string
|
IncompleteReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||||
|
// output token cap or withheld by a content filter) rather than completing
|
||||||
|
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
||||||
|
func (result Result) Truncated() bool {
|
||||||
|
return result.FinishReason != ""
|
||||||
|
}
|
||||||
|
|
||||||
// TruncationNotice returns a user-facing warning when the final response was
|
// TruncationNotice returns a user-facing warning when the final response was
|
||||||
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
||||||
// wording stays consistent.
|
// wording stays consistent.
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type AgentRunner interface {
|
|||||||
|
|
||||||
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
||||||
|
|
||||||
|
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||||
|
return fn(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
||||||
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
||||||
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
|
||||||
package agenteval
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
|
||||||
return fn(ctx, input)
|
|
||||||
}
|
|
||||||
@@ -125,7 +125,9 @@ func Score(suite Suite, input ScoreInput) Report {
|
|||||||
if len(task.RequiredTraceEvents) > 0 {
|
if len(task.RequiredTraceEvents) > 0 {
|
||||||
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
||||||
}
|
}
|
||||||
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
|
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
|
||||||
|
report.Results = append(report.Results, result)
|
||||||
|
}
|
||||||
report.finishSummary()
|
report.finishSummary()
|
||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func ParseTraceEventKeys(stdout string) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TraceEventKeys(stdout string) []string {
|
||||||
|
return ParseTraceEventKeys(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
func MissingTraceEvents(required []string, stdout string) []string {
|
func MissingTraceEvents(required []string, stdout string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, key := range ParseTraceEventKeys(stdout) {
|
for _, key := range ParseTraceEventKeys(stdout) {
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
|
|||||||
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
||||||
|
|
||||||
if info.PrimaryLanguage != "" {
|
if info.PrimaryLanguage != "" {
|
||||||
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
|
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
|
||||||
}
|
}
|
||||||
if langs := topLanguages(info.Languages, 5); langs != "" {
|
if langs := topLanguages(info.Languages, 5); langs != "" {
|
||||||
b.WriteString("- Languages: " + langs + "\n")
|
b.WriteString("- Languages: " + langs + "\n")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
|
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
|
||||||
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
||||||
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
|
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
|
||||||
}
|
}
|
||||||
if len(info.BuildTools) > 0 {
|
if len(info.BuildTools) > 0 {
|
||||||
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
||||||
|
|||||||
@@ -453,12 +453,7 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
|||||||
}
|
}
|
||||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var migrated bool
|
return Task{}, false, err
|
||||||
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
|
||||||
if !migrated {
|
|
||||||
return Task{}, false, err
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
}
|
}
|
||||||
if outputFile != task.OutputFile {
|
if outputFile != task.OutputFile {
|
||||||
changed = true
|
changed = true
|
||||||
@@ -567,32 +562,6 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
|||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateLegacyOutputFile recovers task metadata written under a previous home
|
|
||||||
// or XDG data root. It accepts only the canonical per-task output
|
|
||||||
// name in the historic zero/background layout, and only when the corresponding
|
|
||||||
// regular file already exists inside the active manager root.
|
|
||||||
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
|
|
||||||
requested = strings.TrimSpace(requested)
|
|
||||||
if !filepath.IsAbs(requested) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
requested = filepath.Clean(requested)
|
|
||||||
expectedName := taskID + ".ndjson"
|
|
||||||
if filepath.Base(requested) != expectedName {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
legacyRoot := filepath.Dir(requested)
|
|
||||||
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
canonical := filepath.Join(manager.rootDir, expectedName)
|
|
||||||
info, err := os.Lstat(canonical)
|
|
||||||
if err != nil || !info.Mode().IsRegular() {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return canonical, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func validTaskID(taskID string) bool {
|
func validTaskID(taskID string) bool {
|
||||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package background
|
package background
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -192,88 +191,6 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
const taskID = "legacy_task"
|
|
||||||
canonicalOutput := filepath.Join(root, taskID+".ndjson")
|
|
||||||
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
|
|
||||||
t.Fatalf("write canonical output: %v", err)
|
|
||||||
}
|
|
||||||
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
|
|
||||||
metadata, err := json.Marshal(Task{
|
|
||||||
ID: taskID,
|
|
||||||
Type: "specialist",
|
|
||||||
Status: StatusCompleted,
|
|
||||||
OutputFile: legacyOutput,
|
|
||||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal legacy metadata: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
|
||||||
t.Fatalf("write legacy metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager, err := NewManager(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewManager returned error: %v", err)
|
|
||||||
}
|
|
||||||
task, ok := manager.Get(taskID)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("manager did not load migrated task")
|
|
||||||
}
|
|
||||||
if task.OutputFile != canonicalOutput {
|
|
||||||
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
|
|
||||||
}
|
|
||||||
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
|
|
||||||
t.Fatalf("migration produced load warnings: %#v", warnings)
|
|
||||||
}
|
|
||||||
|
|
||||||
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read migrated metadata: %v", err)
|
|
||||||
}
|
|
||||||
var saved Task
|
|
||||||
if err := json.Unmarshal(persisted, &saved); err != nil {
|
|
||||||
t.Fatalf("decode migrated metadata: %v", err)
|
|
||||||
}
|
|
||||||
if saved.OutputFile != canonicalOutput {
|
|
||||||
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
const taskID = "legacy_task"
|
|
||||||
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
|
|
||||||
t.Fatalf("write canonical output: %v", err)
|
|
||||||
}
|
|
||||||
metadata, err := json.Marshal(Task{
|
|
||||||
ID: taskID,
|
|
||||||
Type: "specialist",
|
|
||||||
Status: StatusCompleted,
|
|
||||||
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
|
|
||||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal metadata: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
|
||||||
t.Fatalf("write metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager, err := NewManager(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewManager returned error: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := manager.Get(taskID); ok {
|
|
||||||
t.Fatal("manager loaded task with an unrelated absolute output path")
|
|
||||||
}
|
|
||||||
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
|
|
||||||
t.Fatalf("load warnings = %q, want containment rejection", warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
manager, err := NewManager(root)
|
manager, err := NewManager(root)
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ package background
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/execution"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
||||||
@@ -23,7 +22,10 @@ var (
|
|||||||
// the negative PID (the group), so any process the child forks dies with it
|
// the negative PID (the group), so any process the child forks dies with it
|
||||||
// instead of being orphaned. Must be called before cmd.Start.
|
// instead of being orphaned. Must be called before cmd.Start.
|
||||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||||
execution.ConfigureProcessGroup(cmd)
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Setpgid = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// terminateProcess stops a background process. It first asks politely with
|
// terminateProcess stops a background process. It first asks politely with
|
||||||
@@ -39,45 +41,71 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
|||||||
// back to signalling only the individual PID, which also avoids reporting a false
|
// back to signalling only the individual PID, which also avoids reporting a false
|
||||||
// success when a non-leader group-signal returns ESRCH.
|
// success when a non-leader group-signal returns ESRCH.
|
||||||
func terminateProcess(pid int) error {
|
func terminateProcess(pid int) error {
|
||||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
|
||||||
|
// every process we can signal. A real child PID is always > 1, so never let a
|
||||||
|
// bogus 0/1 expand into either.
|
||||||
|
if pid <= 1 {
|
||||||
|
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal the whole group only when pid leads its own group; otherwise the
|
||||||
|
// individual process. See the doc comment for why a non-leader is signalled
|
||||||
|
// directly rather than via its (foreign) group.
|
||||||
|
target := pid
|
||||||
|
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||||
|
if pgid == pid {
|
||||||
|
target = -pid
|
||||||
|
}
|
||||||
|
} else if processGoneError(err) {
|
||||||
|
return nil // already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||||
|
|
||||||
|
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||||
|
if processGoneError(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll liveness so we return promptly once it exits, rather than always
|
||||||
|
// waiting out the full grace period.
|
||||||
|
deadline := time.Now().Add(terminationGracePeriod)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !alive() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(terminationPollInterval)
|
||||||
|
}
|
||||||
|
if !alive() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still alive after the grace period: force-kill.
|
||||||
|
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
|
||||||
|
// so this helper only reports success once the target is actually gone —
|
||||||
|
// otherwise the caller gets nil while descendants are still racing.
|
||||||
|
deadline = time.Now().Add(terminationGracePeriod)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !alive() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(terminationPollInterval)
|
||||||
|
}
|
||||||
|
if alive() {
|
||||||
|
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// terminateOwnedProcess terminates cmd's process group directly when its launch
|
// processGoneError reports whether an error means the process group has already
|
||||||
// configuration proves it was made a group leader. This avoids fragile Getpgid
|
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
|
||||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
// no process in the target group remains.
|
||||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
func processGoneError(err error) bool {
|
||||||
//
|
return errors.Is(err, syscall.ESRCH)
|
||||||
// The bool result is whether the leader had already exited before termination
|
|
||||||
// was attempted — the POSIX analogue of Windows GetExitCodeProcess returning a
|
|
||||||
// value other than STILL_ACTIVE. A waitable zombie is already-exited: Wait can
|
|
||||||
// collect it, but kill(0) still succeeds, and when ps is unavailable
|
|
||||||
// signalTargetRunning conservatively treats that as "still running".
|
|
||||||
// TerminateCommand uses the flag to discard the resulting spurious
|
|
||||||
// SIGKILL-timeout once the reap has independently succeeded. The flag is about
|
|
||||||
// the leader only; group/tree termination is still invoked so live descendants
|
|
||||||
// are signalled.
|
|
||||||
//
|
|
||||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
|
||||||
// own convention. A command made its own session leader via Setsid (as opposed
|
|
||||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
|
||||||
// slower rediscovery path here since Setsid isn't checked. Harmless today
|
|
||||||
// because TerminateCommand has exactly one caller in this codebase; worth
|
|
||||||
// covering explicitly if Setsid-configured commands start using this path too.
|
|
||||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
|
||||||
alreadyExited := leaderWaitableExited(cmd.Process.Pid)
|
|
||||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
|
||||||
return alreadyExited, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
|
||||||
}
|
|
||||||
return alreadyExited, terminateProcess(cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// terminationTargetGoneAfterReap independently checks the exact signal target
|
|
||||||
// used for an owned command. In particular, reaping a zombie group leader does
|
|
||||||
// not make this return true while any descendant still occupies that group.
|
|
||||||
func terminationTargetGoneAfterReap(cmd *exec.Cmd) bool {
|
|
||||||
target := cmd.Process.Pid
|
|
||||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
|
||||||
target = -target
|
|
||||||
}
|
|
||||||
return errors.Is(syscall.Kill(target, syscall.Signal(0)), syscall.ESRCH)
|
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -148,12 +147,14 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
|||||||
t.Fatalf("terminateProcess: %v", err)
|
t.Fatalf("terminateProcess: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The forked child must no longer be running. An orphaned zombie is already
|
// The forked child must be gone too (poll until reaped by init).
|
||||||
// dead but may remain visible briefly until the platform's init reaps it.
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
// processStopped treats only ESRCH or an observed zombie state as stopped;
|
for {
|
||||||
// other errors (for example EPERM) remain live and keep polling.
|
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||||
for !processStopped(childPID) {
|
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||||
|
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||||
|
break // child no longer exists
|
||||||
|
}
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||||
@@ -161,176 +162,3 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
|||||||
time.Sleep(20 * time.Millisecond)
|
time.Sleep(20 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
|
|
||||||
cmd := exec.Command("sleep", "30")
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := TerminateCommand(cmd); err != nil {
|
|
||||||
t.Fatalf("TerminateCommand: %v", err)
|
|
||||||
}
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
t.Fatal("unconfigured command was not reaped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
|
|
||||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
|
||||||
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
|
|
||||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
|
||||||
|
|
||||||
// The leader exits immediately after launching the child. TerminateCommand
|
|
||||||
// must capture and signal the process group before Wait reaps the leader and
|
|
||||||
// makes its group identity unavailable.
|
|
||||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
|
||||||
ConfigureChildProcessGroup(cmd)
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stdout pipe: %v", err)
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read forked child pid: %v", err)
|
|
||||||
}
|
|
||||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := TerminateCommand(cmd); err != nil {
|
|
||||||
t.Fatalf("TerminateCommand: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for !processStopped(childPID) {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
|
||||||
t.Fatalf("forked child %d survived TerminateCommand", childPID)
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTerminateCommandZombieLeaderWithoutPS(t *testing.T) {
|
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
|
||||||
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
|
|
||||||
}
|
|
||||||
|
|
||||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
|
||||||
terminationGracePeriod, terminationPollInterval = 150*time.Millisecond, 10*time.Millisecond
|
|
||||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
|
||||||
|
|
||||||
// Issue #862: a zombie group leader with ps unresolvable used to burn both
|
|
||||||
// grace periods (kill(0) succeeds against a zombie) and return
|
|
||||||
// "did not exit after SIGKILL" even though cmd.Wait reaped exit status 0.
|
|
||||||
// The already-exited probe must answer without ps so TerminateCommand can
|
|
||||||
// discard that spurious timeout after a successful reap.
|
|
||||||
cmd := exec.Command("sh", "-c", "exit 0")
|
|
||||||
ConfigureChildProcessGroup(cmd)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
pid := cmd.Process.Pid
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
_ = cmd.Wait()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for !leaderWaitableExited(pid) {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("leader %d did not become waitable-exited within 5s", pid)
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("PATH", "")
|
|
||||||
if err := TerminateCommand(cmd); err != nil {
|
|
||||||
t.Fatalf("TerminateCommand: %v, want nil (zombie leader with ps unavailable must not report SIGKILL timeout)", err)
|
|
||||||
}
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
t.Fatal("zombie leader was not reaped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTerminateCommandPreservesGroupFailureWithExitedLeaderAndLiveChild(t *testing.T) {
|
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
|
||||||
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
|
||||||
ConfigureChildProcessGroup(cmd)
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stdout pipe: %v", err)
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read forked child pid: %v", err)
|
|
||||||
}
|
|
||||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
_ = cmd.Wait()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for !leaderWaitableExited(cmd.Process.Pid) {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("leader %d did not become waitable-exited within 5s", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
treeErr := errors.New("process group could not be terminated")
|
|
||||||
originalTerminateOwnedProcess := terminateOwnedProcessForTest
|
|
||||||
terminateOwnedProcessForTest = func(got *exec.Cmd) (bool, error) {
|
|
||||||
if got != cmd {
|
|
||||||
t.Fatalf("terminate called with %p, want %p", got, cmd)
|
|
||||||
}
|
|
||||||
return true, treeErr
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { terminateOwnedProcessForTest = originalTerminateOwnedProcess })
|
|
||||||
|
|
||||||
err = TerminateCommand(cmd)
|
|
||||||
if !errors.Is(err, treeErr) {
|
|
||||||
t.Fatalf("TerminateCommand error = %v, want live-group termination failure", err)
|
|
||||||
}
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
t.Fatal("exited leader was not reaped")
|
|
||||||
}
|
|
||||||
if processStopped(childPID) {
|
|
||||||
t.Fatalf("child %d unexpectedly stopped; failure boundary needs a live group member", childPID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processStopped(pid int) bool {
|
|
||||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
|
||||||
if err != nil {
|
|
||||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
|
|
||||||
package background
|
|
||||||
|
|
||||||
import "golang.org/x/sys/unix"
|
|
||||||
|
|
||||||
// szomb is sys/proc.h SZOMB: the process has exited and is waiting to be reaped.
|
|
||||||
const szomb int8 = 5
|
|
||||||
|
|
||||||
// leaderWaitableExited reports whether pid has already exited and is waiting
|
|
||||||
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
|
|
||||||
// returning a value other than STILL_ACTIVE: the leader is a zombie, waitable
|
|
||||||
// as exited, without collecting it. A true result does not mean the process
|
|
||||||
// group is empty — descendants may still be running.
|
|
||||||
//
|
|
||||||
// Darwin has no /proc and x/sys/unix does not wrap waitid here, so the probe
|
|
||||||
// is kern.proc.pid via sysctl. It does not use ps, which is the #862 failure
|
|
||||||
// mode when PATH cannot resolve ps.
|
|
||||||
func leaderWaitableExited(pid int) bool {
|
|
||||||
if pid <= 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
kinfo, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return kinfo.Proc.P_stat == szomb
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package background
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
// POSIX waitid si_code values for an already-exited child (bits/waitflags.h).
|
|
||||||
const (
|
|
||||||
cldExited int32 = 1
|
|
||||||
cldKilled int32 = 2
|
|
||||||
cldDumped int32 = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
// leaderWaitableExited reports whether pid has already exited and is waiting
|
|
||||||
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
|
|
||||||
// returning a value other than STILL_ACTIVE: the leader is waitable as exited
|
|
||||||
// (typically a zombie) without collecting it. A true result does not mean the
|
|
||||||
// process group is empty — descendants may still be running.
|
|
||||||
//
|
|
||||||
// Detection does not use ps. waitid(WNOHANG|WNOWAIT) asks the kernel whether
|
|
||||||
// an exit status is already available. /proc/<pid>/stat state Z is the
|
|
||||||
// fallback when waitid cannot answer (for example if procfs is the only
|
|
||||||
// usable source). Either path is independent of PATH, which is the #862
|
|
||||||
// failure mode: empty PATH makes ps unresolvable and kill(0) still succeeds
|
|
||||||
// against a zombie.
|
|
||||||
func leaderWaitableExited(pid int) bool {
|
|
||||||
if pid <= 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if waitidAlreadyExited(pid) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return procIsZombie(pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitidAlreadyExited(pid int) bool {
|
|
||||||
var info unix.Siginfo
|
|
||||||
err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOHANG|unix.WNOWAIT, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch info.Code {
|
|
||||||
case cldExited, cldKilled, cldDumped:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func procIsZombie(pid int) bool {
|
|
||||||
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// /proc/<pid>/stat: pid (comm) state ... — comm may contain spaces or
|
|
||||||
// parentheses, so the state is the first field after the last ')'.
|
|
||||||
i := bytes.LastIndexByte(data, ')')
|
|
||||||
if i < 0 || i+1 >= len(data) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rest := bytes.TrimSpace(data[i+1:])
|
|
||||||
return len(rest) > 0 && rest[0] == 'Z'
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//go:build !windows && !linux && !darwin
|
|
||||||
|
|
||||||
package background
|
|
||||||
|
|
||||||
// leaderWaitableExited cannot positively identify a waitable-exited leader on
|
|
||||||
// this platform without ps. Returning false keeps TerminateCommand's existing
|
|
||||||
// conservative behaviour: a termination error is not discarded after reap.
|
|
||||||
func leaderWaitableExited(pid int) bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -3,55 +3,36 @@
|
|||||||
package background
|
package background
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"github.com/Gitlawb/zero/internal/execution"
|
"strconv"
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
|
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
|
||||||
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
|
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
|
||||||
// constant.
|
|
||||||
const stillActiveExitCode uint32 = 259
|
|
||||||
|
|
||||||
var terminateProcessForTest = terminateProcess
|
|
||||||
|
|
||||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
|
||||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
|
||||||
// setup is required (the POSIX build sets Setpgid here instead).
|
// setup is required (the POSIX build sets Setpgid here instead).
|
||||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
|
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
||||||
|
|
||||||
func terminateProcess(pid int) error {
|
func terminateProcess(pid int) error {
|
||||||
return execution.TerminateProcessTree(pid, 0, 0)
|
taskkill := taskkillPath()
|
||||||
}
|
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||||
|
return nil
|
||||||
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
|
|
||||||
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
|
|
||||||
// can discover descendants only while the root PID still exists; unlike a POSIX
|
|
||||||
// process group, a Windows tree has no independently addressable identity after
|
|
||||||
// its root exits.
|
|
||||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
|
||||||
var (
|
|
||||||
alreadyExited bool
|
|
||||||
terminateErr error
|
|
||||||
)
|
|
||||||
err := cmd.Process.WithHandle(func(handle uintptr) {
|
|
||||||
var exitCode uint32
|
|
||||||
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
|
|
||||||
alreadyExited = exitCode != stillActiveExitCode
|
|
||||||
}
|
|
||||||
// Keep the exact process identity pinned while the PID-based taskkill
|
|
||||||
// operation runs, preventing the PID from being recycled underneath it.
|
|
||||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
|
||||||
}
|
}
|
||||||
return alreadyExited, terminateErr
|
process, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return process.Kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows has no persistent process-group identity to query after a dead root
|
func taskkillPath() string {
|
||||||
// is reaped. Preserve TerminateCommand's existing, documented dead-root
|
systemRoot := os.Getenv("SystemRoot")
|
||||||
// behavior; the stronger independent target check is available on POSIX.
|
if systemRoot == "" {
|
||||||
func terminationTargetGoneAfterReap(*exec.Cmd) bool { return true }
|
systemRoot = os.Getenv("windir")
|
||||||
|
}
|
||||||
|
if systemRoot == "" {
|
||||||
|
systemRoot = `C:\Windows`
|
||||||
|
}
|
||||||
|
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package background
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
|
|
||||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
treeErr := errors.New("taskkill /T failed")
|
|
||||||
originalTerminateProcess := terminateProcessForTest
|
|
||||||
terminateProcessForTest = func(pid int) error {
|
|
||||||
if err := cmd.Process.Kill(); err != nil {
|
|
||||||
return errors.Join(treeErr, err)
|
|
||||||
}
|
|
||||||
return treeErr
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
|
|
||||||
|
|
||||||
err := TerminateCommand(cmd)
|
|
||||||
if !errors.Is(err, treeErr) {
|
|
||||||
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
|
|
||||||
}
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
t.Fatal("root process was not reaped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
|
|
||||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
// Let the child exit without calling Wait so cleanup exercises the Windows
|
|
||||||
// taskkill/TerminateProcess race while it still owns the process handle.
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
|
|
||||||
// Process.Kill fallback) both fail here, but the reap below still succeeds —
|
|
||||||
// the process-handle exit-code check proves the leader was already gone, so
|
|
||||||
// TerminateCommand must not surface the expected kill-attempt error.
|
|
||||||
if err := TerminateCommand(cmd); err != nil {
|
|
||||||
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
|
|
||||||
}
|
|
||||||
if cmd.ProcessState == nil {
|
|
||||||
t.Fatal("exited process was not reaped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
|
|
||||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
|
||||||
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stdout pipe: %v", err)
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read descendant pid: %v", err)
|
|
||||||
}
|
|
||||||
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse descendant pid %q: %v", line, err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait until the leader is observably exited but deliberately leave it
|
|
||||||
// unreaped, matching the POSIX regression's ordering.
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for processExistsOnWindows(cmd.Process.Pid) {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatal("leader did not exit")
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if err := TerminateCommand(cmd); err != nil {
|
|
||||||
t.Fatalf("TerminateCommand: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows has no persistent group ID analogous to POSIX: once the leader is
|
|
||||||
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
|
|
||||||
// distinction rather than claiming TerminateCommand can provide it here.
|
|
||||||
if !processExistsOnWindows(descendantPID) {
|
|
||||||
t.Fatal("descendant unexpectedly stopped after its root exited")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processExistsOnWindows(pid int) bool {
|
|
||||||
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
|
||||||
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
package background
|
package background
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const commandReapTimeout = 3 * time.Second
|
|
||||||
|
|
||||||
// terminateOwnedProcessForTest is a seam for the failure-boundary tests. Tests
|
|
||||||
// replace it only while no other test in this package is running in parallel.
|
|
||||||
var terminateOwnedProcessForTest = terminateOwnedProcess
|
|
||||||
|
|
||||||
// TerminateProcess stops a background process by PID — on Windows its process
|
// TerminateProcess stops a background process by PID — on Windows its process
|
||||||
// tree; on POSIX its whole process group when the PID leads its own group (the
|
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||||
@@ -25,77 +12,3 @@ var terminateOwnedProcessForTest = terminateOwnedProcess
|
|||||||
func TerminateProcess(pid int) error {
|
func TerminateProcess(pid int) error {
|
||||||
return terminateProcess(pid)
|
return terminateProcess(pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TerminateCommand stops a started command and reaps its leader. The caller must
|
|
||||||
// have exclusive ownership of cmd: it must not have previously called Wait or
|
|
||||||
// Process.Release, and no goroutine may call either concurrently. On POSIX it
|
|
||||||
// stops the whole group when the command was configured as its leader; ordinary
|
|
||||||
// commands safely fall back to PID/tree discovery. When POSIX observes a
|
|
||||||
// waitable-zombie leader before signalling, a termination error is discarded
|
|
||||||
// after reap only if the launch-time signal target is independently gone; the
|
|
||||||
// leader observation alone says nothing about descendants. Windows retains its
|
|
||||||
// existing behavior of ignoring the expected kill-attempt error when
|
|
||||||
// GetExitCodeProcess shows that the root was already dead. Windows cannot
|
|
||||||
// rediscover a tree from a dead root, so descendants may survive there. `zero
|
|
||||||
// daemon start` needs this operation when readiness times out: it launched the
|
|
||||||
// child, so it must both stop the tree and collect the leader.
|
|
||||||
//
|
|
||||||
// The order matters: the tree is signalled first, because Wait releases the
|
|
||||||
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
|
|
||||||
// to a recycled PID). Termination stays in the shared execution lifecycle
|
|
||||||
// primitives rather than introducing a second platform-specific killer.
|
|
||||||
//
|
|
||||||
// If the bounded reap times out, the Wait call already started by this function
|
|
||||||
// continues in its goroutine. From that point onward the caller must not access
|
|
||||||
// cmd at all, including ProcessState, because that would race with Wait.
|
|
||||||
func TerminateCommand(cmd *exec.Cmd) error {
|
|
||||||
if cmd == nil || cmd.Process == nil {
|
|
||||||
return errors.New("terminate command: process was never started")
|
|
||||||
}
|
|
||||||
leaderAlreadyExited, terminateErr := terminateOwnedProcessForTest(cmd)
|
|
||||||
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
|
|
||||||
if reapErr != nil {
|
|
||||||
if terminateErr != nil {
|
|
||||||
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
|
|
||||||
}
|
|
||||||
return reapErr
|
|
||||||
}
|
|
||||||
if terminateErr != nil {
|
|
||||||
if !leaderAlreadyExited || !terminationTargetGoneAfterReap(cmd) {
|
|
||||||
return terminateErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// On POSIX, discard a termination error only when the leader was already
|
|
||||||
// exited and the launch-time signal target is independently gone after reap.
|
|
||||||
// A successful leader reap alone says nothing about live descendants.
|
|
||||||
// Windows retains its existing dead-root semantics because it has no
|
|
||||||
// persistent group identity to query after the root exits.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
|
||||||
// terminated is expected to report one (or a signal), so the only interesting
|
|
||||||
// failure is Wait itself not working.
|
|
||||||
func classifyWaitError(waitErr error) error {
|
|
||||||
var exitErr *exec.ExitError
|
|
||||||
if waitErr != nil && !errors.As(waitErr, &exitErr) {
|
|
||||||
return fmt.Errorf("reap process: %w", waitErr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
|
|
||||||
// termination cannot hang the caller — the daemon-start cleanup path runs while a
|
|
||||||
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
|
|
||||||
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
|
|
||||||
// would leak the child once it eventually exits.
|
|
||||||
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
|
|
||||||
waitDone := make(chan error, 1)
|
|
||||||
go func() { waitDone <- cmd.Wait() }()
|
|
||||||
select {
|
|
||||||
case waitErr := <-waitDone:
|
|
||||||
return classifyWaitError(waitErr)
|
|
||||||
case <-time.After(timeout):
|
|
||||||
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-87
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,11 +9,8 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/acp"
|
"github.com/Gitlawb/zero/internal/acp"
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/execution"
|
|
||||||
"github.com/Gitlawb/zero/internal/mcp"
|
|
||||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
|
||||||
"github.com/Gitlawb/zero/internal/redaction"
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
|
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
|
||||||
@@ -29,8 +25,6 @@ Usage:
|
|||||||
Not meant to be run interactively — point your editor's ACP / external-agent
|
Not meant to be run interactively — point your editor's ACP / external-agent
|
||||||
setting at "zero acp".`
|
setting at "zero acp".`
|
||||||
|
|
||||||
var acpSignalContext = signalContext
|
|
||||||
|
|
||||||
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
||||||
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
||||||
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
||||||
@@ -46,105 +40,42 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The process's own stdin belongs exclusively to this Conn for the life of
|
conn := acp.NewConn(deps.stdin, stdout)
|
||||||
// the command, so cancellation may close it to interrupt an idle read.
|
|
||||||
conn := acp.NewOwnedConn(deps.stdin, stdout)
|
|
||||||
acp.NewAgent(conn, acp.Deps{
|
acp.NewAgent(conn, acp.Deps{
|
||||||
ResolveConfig: deps.resolveConfig,
|
ResolveConfig: deps.resolveConfig,
|
||||||
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
|
||||||
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
|
||||||
},
|
|
||||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||||
// surface — no ACP-specific credential handling needed.
|
// surface — no ACP-specific credential handling needed.
|
||||||
NewProvider: deps.newProvider,
|
NewProvider: deps.newProvider,
|
||||||
RunAgent: agent.Run,
|
RunAgent: agent.Run,
|
||||||
BuildWorkspace: func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*acp.Workspace, error) {
|
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
|
||||||
return buildACPWorkspace(ctx, workspaceRoot, resolved, mode, deps)
|
// exec surface, so ACP shell/file tools are confined — never run unconfined.
|
||||||
|
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||||
|
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||||
|
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||||
|
return registry, engine, nil
|
||||||
},
|
},
|
||||||
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
||||||
Store: deps.newSessionStore(),
|
Store: deps.newSessionStore(),
|
||||||
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, stop := acpSignalContext()
|
ctx, stop := signalContext()
|
||||||
defer stop()
|
defer stop()
|
||||||
if err := conn.Serve(ctx); err != nil {
|
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
|
||||||
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
||||||
}
|
}
|
||||||
return exitSuccess
|
return exitSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildACPWorkspace matches exec's registry construction for one ACP turn. MCP
|
|
||||||
// servers use the same sandbox-prepared execution runner, project configuration is
|
|
||||||
// gated by the validated workspace's trust state, and their runtime is released
|
|
||||||
// when acp.Agent completes the turn. ACP deliberately stays at low autonomy: an
|
|
||||||
// editor connection never upgrades MCP permissions beyond an interactive prompt.
|
|
||||||
func buildACPWorkspace(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode, deps appDeps) (*acp.Workspace, error) {
|
|
||||||
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
|
||||||
|
|
||||||
workspace := &acp.Workspace{
|
|
||||||
Registry: registry, Sandbox: engine,
|
|
||||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
|
||||||
}
|
|
||||||
if mode != agent.PermissionModePlan {
|
|
||||||
// MCP stdio servers are subprocesses. Passing the engine to their runner
|
|
||||||
// keeps them inside the exact sandbox / lifecycle path used by zero exec.
|
|
||||||
runtime, trustSkip, err := registerMCPToolsForWorkspaceWithOptions(
|
|
||||||
ctx, workspaceRoot, registry, deps, mcp.AutonomyLow, workspaceRoot,
|
|
||||||
mcp.RegisterOptions{Execution: execution.NewRunner(engine), AdvertiseInAuto: true},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
// RegisterTools may have connected an earlier server before reporting a
|
|
||||||
// later failure, so do not orphan a partial runtime on this error path.
|
|
||||||
if runtime != nil {
|
|
||||||
_ = runtime.Close()
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
workspace.Cleanup = runtime.Close
|
|
||||||
workspace.Notices = acpMCPSetupNotices(trustSkip, runtime)
|
|
||||||
}
|
|
||||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
|
||||||
// MCP tools are deferred-eligible. Register their loader only after every
|
|
||||||
// ACP-visible tool is present, using the same mode the agent receives.
|
|
||||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, mode, nil, nil)
|
|
||||||
return workspace, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func acpMCPSetupNotices(skip trustSkip, runtime mcpToolRuntime) []string {
|
|
||||||
var notices []string
|
|
||||||
if skip.excludedProjectConfig {
|
|
||||||
if skip.trustCheckErrored {
|
|
||||||
notices = append(notices, "The workspace-trust store could not be read; project MCP servers were ignored (fail-closed). Run 'zero trust' to enable them.")
|
|
||||||
} else {
|
|
||||||
notices = append(notices, "Project MCP servers were ignored in this untrusted workspace. Run 'zero trust' to enable them.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runtime == nil {
|
|
||||||
return notices
|
|
||||||
}
|
|
||||||
for _, skipped := range runtime.Skipped() {
|
|
||||||
if skipped.UnconfiguredDefault {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
message := fmt.Sprintf("MCP server %s unavailable, skipped", skipped.Name)
|
|
||||||
if skipped.Err != nil {
|
|
||||||
message += ": " + redaction.ErrorMessage(skipped.Err, redaction.Options{})
|
|
||||||
}
|
|
||||||
notices = append(notices, redaction.RedactString(message, redaction.Options{}))
|
|
||||||
}
|
|
||||||
return notices
|
|
||||||
}
|
|
||||||
|
|
||||||
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
|
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
|
||||||
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
|
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
|
||||||
// dir) and additionally rejects the filesystem root and the home directory — an
|
// dir) and additionally rejects the filesystem root and the home directory — an
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
|
||||||
"github.com/Gitlawb/zero/internal/mcp"
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
|
||||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
|
||||||
)
|
|
||||||
|
|
||||||
type acpTestReader func([]byte) (int, error)
|
|
||||||
|
|
||||||
func (r acpTestReader) Read(p []byte) (int, error) { return r(p) }
|
|
||||||
|
|
||||||
type acpTestWriter func([]byte) (int, error)
|
|
||||||
|
|
||||||
func (w acpTestWriter) Write(p []byte) (int, error) { return w(p) }
|
|
||||||
|
|
||||||
func installACPTestContext(t *testing.T) context.CancelFunc {
|
|
||||||
t.Helper()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
previous := acpSignalContext
|
|
||||||
acpSignalContext = func() (context.Context, context.CancelFunc) {
|
|
||||||
return ctx, cancel
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cancel()
|
|
||||||
acpSignalContext = previous
|
|
||||||
})
|
|
||||||
return cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunACPCancellationPreservesTerminalReadError(t *testing.T) {
|
|
||||||
cancel := installACPTestContext(t)
|
|
||||||
wantErr := errors.New("transport read failed")
|
|
||||||
reader := acpTestReader(func(p []byte) (int, error) {
|
|
||||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
|
||||||
})
|
|
||||||
writeStarted := make(chan struct{}, 1)
|
|
||||||
releaseWrite := make(chan struct{})
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
writer := acpTestWriter(func(p []byte) (int, error) {
|
|
||||||
writeStarted <- struct{}{}
|
|
||||||
<-releaseWrite
|
|
||||||
return stdout.Write(p)
|
|
||||||
})
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
done := make(chan int, 1)
|
|
||||||
go func() {
|
|
||||||
done <- runACP(nil, writer, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-writeStarted:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("ACP did not reach the response write")
|
|
||||||
}
|
|
||||||
// Keep Serve in the synchronous write until cancellation is observable. The
|
|
||||||
// regression requires the genuine read error to win over that cancellation.
|
|
||||||
cancel()
|
|
||||||
close(releaseWrite)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case code := <-done:
|
|
||||||
if code != exitCrash {
|
|
||||||
t.Fatalf("exit code = %d, want crash %d", code, exitCrash)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("ACP did not exit after terminal read error")
|
|
||||||
}
|
|
||||||
if got := stderr.String(); !strings.Contains(got, "acp: "+wantErr.Error()) {
|
|
||||||
t.Fatalf("stderr = %q, want terminal read error", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
|
|
||||||
cancel := installACPTestContext(t)
|
|
||||||
pipeReader, pipeWriter := io.Pipe()
|
|
||||||
t.Cleanup(func() { _ = pipeWriter.Close() })
|
|
||||||
readStarted := make(chan struct{}, 1)
|
|
||||||
reader := &acpNotifyingReadCloser{ReadCloser: pipeReader, readStarted: readStarted}
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
done := make(chan int, 1)
|
|
||||||
go func() {
|
|
||||||
done <- runACP(nil, &stdout, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-readStarted:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("ACP did not begin reading")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case code := <-done:
|
|
||||||
if code != exitSuccess {
|
|
||||||
t.Fatalf("exit code = %d, want success %d; stderr: %s", code, exitSuccess, stderr.String())
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("ACP did not exit after cancellation")
|
|
||||||
}
|
|
||||||
if stderr.Len() != 0 {
|
|
||||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildACPWorkspaceRegistersSandboxedMCPToolsAndClosesThem(t *testing.T) {
|
|
||||||
setTrustConfigRoot(t)
|
|
||||||
workspaceRoot := t.TempDir()
|
|
||||||
if err := workspacetrust.Trust(workspaceRoot); err != nil {
|
|
||||||
t.Fatalf("trust workspace: %v", err)
|
|
||||||
}
|
|
||||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new grant store: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var gotExclude, registered, closed bool
|
|
||||||
deps := fillAppDeps(appDeps{
|
|
||||||
resolveMCPConfig: func(root string, excludeProject bool) (config.MCPConfig, error) {
|
|
||||||
if root != workspaceRoot {
|
|
||||||
t.Fatalf("MCP workspace root = %q, want %q", root, workspaceRoot)
|
|
||||||
}
|
|
||||||
gotExclude = excludeProject
|
|
||||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
||||||
"docs": {Type: "stdio", Command: "fake-docs"},
|
|
||||||
}}, nil
|
|
||||||
},
|
|
||||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
|
||||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
|
||||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
||||||
if ctx == nil {
|
|
||||||
t.Fatal("MCP registration received nil context")
|
|
||||||
}
|
|
||||||
if len(cfg.Servers) != 1 || cfg.Servers["docs"].Command != "fake-docs" {
|
|
||||||
t.Fatalf("MCP config = %+v", cfg)
|
|
||||||
}
|
|
||||||
if options.Autonomy != mcp.AutonomyLow {
|
|
||||||
t.Fatalf("MCP autonomy = %q, want low", options.Autonomy)
|
|
||||||
}
|
|
||||||
if options.Execution == nil {
|
|
||||||
t.Fatal("MCP stdio server was not given the sandbox execution runner")
|
|
||||||
}
|
|
||||||
if options.WorkspaceRoot != workspaceRoot {
|
|
||||||
t.Fatalf("MCP execution workspace = %q, want %q", options.WorkspaceRoot, workspaceRoot)
|
|
||||||
}
|
|
||||||
if !options.AdvertiseInAuto {
|
|
||||||
t.Fatal("ACP MCP tools must be advertised in auto mode without being pre-approved")
|
|
||||||
}
|
|
||||||
registered = true
|
|
||||||
registry.Register(cliFakeDeferredTool{
|
|
||||||
name: "mcp_docs_search", permission: tools.PermissionPrompt,
|
|
||||||
advertiseInAuto: options.AdvertiseInAuto,
|
|
||||||
})
|
|
||||||
return closeFunc(func() error {
|
|
||||||
closed = true
|
|
||||||
return nil
|
|
||||||
}), nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{
|
|
||||||
Tools: config.ToolsConfig{DeferThreshold: 1},
|
|
||||||
}, agent.PermissionModeAuto, deps)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildACPWorkspace: %v", err)
|
|
||||||
}
|
|
||||||
if gotExclude {
|
|
||||||
t.Fatal("trusted ACP workspace excluded project MCP configuration")
|
|
||||||
}
|
|
||||||
if !registered {
|
|
||||||
t.Fatal("ACP workspace did not register configured MCP tools")
|
|
||||||
}
|
|
||||||
if _, ok := workspace.Registry.Get("mcp_docs_search"); !ok {
|
|
||||||
t.Fatal("MCP tool is absent from ACP agent registry")
|
|
||||||
}
|
|
||||||
if _, ok := workspace.Registry.Get(tools.ToolSearchToolName); !ok {
|
|
||||||
t.Fatal("ACP registry is missing tool_search for deferred MCP tools")
|
|
||||||
}
|
|
||||||
if workspace.DeferThreshold != 1 {
|
|
||||||
t.Fatalf("workspace defer threshold = %d, want 1", workspace.DeferThreshold)
|
|
||||||
}
|
|
||||||
if err := workspace.Close(); err != nil {
|
|
||||||
t.Fatalf("close ACP workspace: %v", err)
|
|
||||||
}
|
|
||||||
if !closed {
|
|
||||||
t.Fatal("ACP workspace did not close its MCP runtime")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildACPWorkspacePreservesRedactedMCPSetupNotices(t *testing.T) {
|
|
||||||
setTrustConfigRoot(t)
|
|
||||||
workspaceRoot := t.TempDir()
|
|
||||||
if err := os.MkdirAll(filepath.Join(workspaceRoot, ".zero"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
projectConfig := `{"mcp":{"servers":{"project-docs":{"type":"stdio","command":"project-docs"}}}}`
|
|
||||||
if err := os.WriteFile(filepath.Join(workspaceRoot, ".zero", "config.json"), []byte(projectConfig), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "grants.json")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
deps := fillAppDeps(appDeps{
|
|
||||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
|
||||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
||||||
"docs": {Type: "stdio", Command: "fake-docs"},
|
|
||||||
}}, nil
|
|
||||||
},
|
|
||||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
|
||||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
|
||||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
||||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{{
|
|
||||||
Name: "docs", Err: errors.New("token=" + secret),
|
|
||||||
}}}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer workspace.Close()
|
|
||||||
joined := strings.Join(workspace.Notices, "\n")
|
|
||||||
if !strings.Contains(joined, "untrusted workspace") || !strings.Contains(joined, "MCP server docs unavailable") {
|
|
||||||
t.Fatalf("workspace notices = %#v", workspace.Notices)
|
|
||||||
}
|
|
||||||
if strings.Contains(joined, secret) || !strings.Contains(joined, "[REDACTED]") {
|
|
||||||
t.Fatalf("workspace notices leaked secret: %q", joined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildACPWorkspaceSkipsMCPInPlanMode(t *testing.T) {
|
|
||||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new grant store: %v", err)
|
|
||||||
}
|
|
||||||
called := false
|
|
||||||
deps := fillAppDeps(appDeps{
|
|
||||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
|
||||||
called = true
|
|
||||||
return config.MCPConfig{}, nil
|
|
||||||
},
|
|
||||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
|
||||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
||||||
called = true
|
|
||||||
return nil, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModePlan, deps)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildACPWorkspace: %v", err)
|
|
||||||
}
|
|
||||||
if called {
|
|
||||||
t.Fatal("plan-mode ACP workspace must not resolve or start MCP servers")
|
|
||||||
}
|
|
||||||
if err := workspace.Close(); err != nil {
|
|
||||||
t.Fatalf("close plan workspace: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildACPWorkspaceClosesPartialMCPRuntimeOnRegistrationError(t *testing.T) {
|
|
||||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new grant store: %v", err)
|
|
||||||
}
|
|
||||||
closed := false
|
|
||||||
deps := fillAppDeps(appDeps{
|
|
||||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
|
||||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
||||||
"partial": {Type: "stdio", Command: "fake-partial"},
|
|
||||||
}}, nil
|
|
||||||
},
|
|
||||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
|
||||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
|
||||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
||||||
return closeFunc(func() error {
|
|
||||||
closed = true
|
|
||||||
return nil
|
|
||||||
}), errors.New("second MCP server failed")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("buildACPWorkspace succeeded after MCP registration error")
|
|
||||||
}
|
|
||||||
if workspace != nil {
|
|
||||||
t.Fatal("buildACPWorkspace returned a workspace after MCP registration error")
|
|
||||||
}
|
|
||||||
if !closed {
|
|
||||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type acpNotifyingReadCloser struct {
|
|
||||||
io.ReadCloser
|
|
||||||
readStarted chan<- struct{}
|
|
||||||
once sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *acpNotifyingReadCloser) Read(p []byte) (int, error) {
|
|
||||||
r.once.Do(func() { r.readStarted <- struct{}{} })
|
|
||||||
return r.ReadCloser.Read(p)
|
|
||||||
}
|
|
||||||
Loaded 100 of 872 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user