mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
142
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fba859ea | ||
|
|
faae5e8aef | ||
|
|
7871d95d76 | ||
|
|
f4ed9cdcaf | ||
|
|
0a7c42824f | ||
|
|
8452b471c6 | ||
|
|
b73c2b2104 | ||
|
|
7c7931ee73 | ||
|
|
848f9fe09b | ||
|
|
100d4676ca | ||
|
|
6fab186efd | ||
|
|
95b0bd0dc7 | ||
|
|
3429c0c4bc | ||
|
|
b6e3ceeb62 | ||
|
|
4de65ba5c9 | ||
|
|
a86baf512d | ||
|
|
e4ef984224 | ||
|
|
1c74b2374d | ||
|
|
7ed1098ef2 | ||
|
|
f1bfdd2803 | ||
|
|
51895859e6 | ||
|
|
5d67c187b4 | ||
|
|
f09d1eb514 | ||
|
|
e5d901e2f9 | ||
|
|
73c4fb7b23 | ||
|
|
926a999421 | ||
|
|
9da7ac5d53 | ||
|
|
bc651c2e4a | ||
|
|
805c102aae | ||
|
|
42b11d3e38 | ||
|
|
d4dde5a944 | ||
|
|
0f93b5fbef | ||
|
|
7a3a5a0491 | ||
|
|
886fd11324 | ||
|
|
987efee70d | ||
|
|
218d746a83 | ||
|
|
16caa3b953 | ||
|
|
ffc18e4097 | ||
|
|
0030609e85 | ||
|
|
6077a42f25 | ||
|
|
ef1dbf0346 | ||
|
|
fa8de4a390 | ||
|
|
ecdbed476c | ||
|
|
884cb7d55f | ||
|
|
171b5d383b | ||
|
|
9624413504 | ||
|
|
a472b676db | ||
|
|
99721c762f | ||
|
|
dcf229fecd | ||
|
|
3fae0de2ea | ||
|
|
4ba112a0eb | ||
|
|
b76c764b77 | ||
|
|
d73ec81e2d | ||
|
|
2479d946c1 | ||
|
|
d76f7f6931 | ||
|
|
37d69c96ce | ||
|
|
176f5a242b | ||
|
|
9e609c5242 | ||
|
|
2f531178f6 | ||
|
|
3b43c43eca | ||
|
|
faec8ee201 | ||
|
|
c1937dfac7 | ||
|
|
90014210d2 | ||
|
|
6937a309cf | ||
|
|
da244bcbfc | ||
|
|
e34c8d2252 | ||
|
|
91d921f4c5 | ||
|
|
6ae8396f02 | ||
|
|
2837f5efb3 | ||
|
|
08fbea7a32 | ||
|
|
82ae3a8809 | ||
|
|
488def68dc | ||
|
|
b04fe96e9e | ||
|
|
79fa1422ef | ||
|
|
1aacf38b72 | ||
|
|
3063593129 | ||
|
|
d6b25fc303 | ||
|
|
7c50a9fb98 | ||
|
|
275e248669 | ||
|
|
0207f2b269 | ||
|
|
8b3d1b1597 | ||
|
|
a3852bac59 | ||
|
|
a3e7446681 | ||
|
|
0021475d4c | ||
|
|
deba3adfca | ||
|
|
501f32160f | ||
|
|
1e4db7c664 | ||
|
|
f9092668a8 | ||
|
|
7f5e5af7bb | ||
|
|
f30f550e60 | ||
|
|
aadb4a27e9 | ||
|
|
a6ec0435e3 | ||
|
|
1b5db17656 | ||
|
|
eeea3308fe | ||
|
|
27b319ca88 | ||
|
|
1fde941803 | ||
|
|
7acac7acee | ||
|
|
1649008506 | ||
|
|
6fe0d1edcd | ||
|
|
e59bf78cab | ||
|
|
6645f9744b | ||
|
|
2749447cdc | ||
|
|
ad34dc8d81 | ||
|
|
6edf9a8b78 | ||
|
|
90dcfd127e | ||
|
|
01c3498205 | ||
|
|
1ec7219aee | ||
|
|
1a81292d3f | ||
|
|
a78c6c357e | ||
|
|
8dd33ec39e | ||
|
|
d7ac85cbf9 | ||
|
|
d66ad71532 | ||
|
|
d065467cd3 | ||
|
|
0eab63c908 | ||
|
|
64a783b08c | ||
|
|
9c292092a2 | ||
|
|
060d38ce71 | ||
|
|
04fd3c01a7 | ||
|
|
dc15e82298 | ||
|
|
2d2450e9a7 | ||
|
|
cabfeefc4e | ||
|
|
0c1de25a04 | ||
|
|
2458e0c67b | ||
|
|
027a67080b | ||
|
|
fb4f293c20 | ||
|
|
ccc42b94e7 | ||
|
|
2ec5c3ae6b | ||
|
|
ff62f73b7f | ||
|
|
0176ee49ff | ||
|
|
160e3bed1a | ||
|
|
7fbd864e91 | ||
|
|
020c28eaf9 | ||
|
|
21e1e8d072 | ||
|
|
936aa4e77c | ||
|
|
30f6d8f591 | ||
|
|
c52db055b0 | ||
|
|
55fa05f0ab | ||
|
|
1057d74512 | ||
|
|
f922cb378e | ||
|
|
7f39a63067 | ||
|
|
ff608c7834 | ||
|
|
57858a31d1 |
No files matched your search
@@ -29,6 +29,9 @@ reviews:
|
||||
- path: "tests/**/*.ts"
|
||||
instructions: |
|
||||
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
||||
- path: "**/*.go"
|
||||
instructions: |
|
||||
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
|
||||
tools:
|
||||
github-checks:
|
||||
timeout_ms: 300000
|
||||
|
||||
+153
-75
@@ -6,10 +6,109 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Code Quality & Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Check formatting
|
||||
run: make fmt-check
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: make deadcode
|
||||
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
|
||||
test:
|
||||
name: Unit Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test with coverage
|
||||
run: |
|
||||
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Publish coverage summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f coverage.out ]; then
|
||||
go tool cover -func=coverage.out | awk '
|
||||
BEGIN {
|
||||
total = "unknown"
|
||||
rows = ""
|
||||
}
|
||||
$1 == "total:" {
|
||||
total = $3
|
||||
next
|
||||
}
|
||||
{
|
||||
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
|
||||
}
|
||||
END {
|
||||
print "### 📊 Test Coverage Summary: **" total "**"
|
||||
print "<details><summary>Coverage per function</summary>\n"
|
||||
print "| File | Function | Coverage |"
|
||||
print "| :--- | :--- | :--- |"
|
||||
printf "%s", rows
|
||||
print "</details>"
|
||||
}' >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-coverage-report
|
||||
path: coverage.out
|
||||
if-no-files-found: warn
|
||||
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -18,64 +117,22 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install Make on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
|
||||
- name: Validate quality targets with stock macOS Make
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: grep.patternType
|
||||
GIT_CONFIG_VALUE_0: fixed
|
||||
run: make -n vulncheck
|
||||
|
||||
- name: Check vulnerabilities through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: make vulncheck
|
||||
|
||||
- name: Check dead code through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make deadcode
|
||||
|
||||
- name: Run static lint through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make lint-static
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:" >&2
|
||||
echo "$unformatted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
- name: Test (Fast Non-Race)
|
||||
run: go test ./...
|
||||
|
||||
- name: Build binary
|
||||
@@ -84,9 +141,15 @@ jobs:
|
||||
- name: Smoke binary
|
||||
run: go run ./cmd/zero-release smoke
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
race:
|
||||
name: Race Detector
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# A race the detector newly reports can present as a deadlock rather than a
|
||||
# failure, and the default job timeout is six hours. This job is expected to
|
||||
# be the longest in the workflow, so the ceiling is generous but finite.
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -100,55 +163,70 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Invoke the Makefile target rather than repeating its flags. `make test`
|
||||
# is already `go test ./... -race -count=1`, so running it here is what
|
||||
# stops the project's declared test command and what CI actually runs
|
||||
# from drifting apart again. The race detector needs cgo, which the
|
||||
# hosted ubuntu runner supplies without an extra setup step.
|
||||
- name: Test with the race detector
|
||||
run: make test
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binary
|
||||
run: go run ./cmd/zero-release build
|
||||
|
||||
- name: Performance smoke
|
||||
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
run: |
|
||||
mkdir -p dist/perf
|
||||
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
|
||||
- name: Upload performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-performance-smoke
|
||||
path: dist/perf/perf-bench.json
|
||||
if-no-files-found: warn
|
||||
|
||||
security:
|
||||
name: Security & code health
|
||||
name: Security & Code Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||
- name: govulncheck
|
||||
- name: govulncheck (linux)
|
||||
run: make vulncheck
|
||||
|
||||
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||
# still being wired or removed.
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
- name: govulncheck (windows)
|
||||
run: make vulncheck-windows
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
|
||||
@@ -34,12 +34,15 @@ jobs:
|
||||
- name: Checkout
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
@@ -129,6 +132,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
||||
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
@@ -12,8 +14,13 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate action.yml
|
||||
@@ -24,6 +31,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run action-summary test suite
|
||||
run: node --test scripts/action-summary.test.mjs
|
||||
|
||||
- name: Parse action.yml and assert documented inputs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.6.0"}
|
||||
{".":"0.9.0"}
|
||||
@@ -81,6 +81,13 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
||||
checks over duplicated helpers that drift.
|
||||
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
|
||||
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
|
||||
`windows-x64`) and the additional supported Android/Termux source-build and
|
||||
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
|
||||
`plan9`, are unsupported and are not a review criterion. Do not block a PR
|
||||
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
|
||||
`plan9` as a fixture for "unsupported".
|
||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
||||
and permission checks. Do not rely on pre-open path resolution
|
||||
@@ -91,6 +98,14 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
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
|
||||
@@ -103,3 +118,42 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
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.
|
||||
+112
@@ -6,6 +6,118 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.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)
|
||||
|
||||
|
||||
|
||||
@@ -126,6 +126,31 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
|
||||
specific bug, addressing a security issue, improving compatibility, or supporting
|
||||
an approved feature.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
Zero publishes five native prebuilt artifacts from
|
||||
`.github/workflows/release-artifacts.yml`:
|
||||
|
||||
- `linux-x64` (`linux/amd64`)
|
||||
- `linux-arm64` (`linux/arm64`)
|
||||
- `macos-arm64` (`darwin/arm64`)
|
||||
- `macos-x64` (`darwin/amd64`)
|
||||
- `windows-x64` (`windows/amd64`)
|
||||
|
||||
The shell installer and npm wrapper resolve the applicable artifacts from that
|
||||
list. The PowerShell installer supports the published `windows-x64` artifact;
|
||||
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
|
||||
build under emulation or build from source.
|
||||
|
||||
Android/Termux is also supported even though it has no distinct release
|
||||
artifact. The npm wrapper maps Android to the Linux artifact, and
|
||||
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
|
||||
devices whose seccomp policy blocks the Linux build's syscall path.
|
||||
|
||||
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
|
||||
review criterion. Do not block a pull request for failing to compile on an
|
||||
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
|
||||
|
||||
## What We Prioritize
|
||||
|
||||
The core team will prioritize:
|
||||
|
||||
@@ -7,7 +7,7 @@ DEADCODE_VERSION := v0.46.0
|
||||
GOLANGCI_LINT_VERSION := v2.12.2
|
||||
GOVULNCHECK_VERSION := v1.3.0
|
||||
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -44,7 +44,8 @@ lint: fmt-check vet
|
||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||
# The target-specific export is shell-independent.
|
||||
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -55,6 +56,11 @@ deadcode:
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
vulncheck-windows:
|
||||
mkdir -p "$(CURDIR)/.cache/gobin"
|
||||
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -77,4 +83,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
@@ -25,7 +26,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
|
||||
OpenAI-/Anthropic-compatible endpoint.
|
||||
- **Stay in control.** File writes, shell commands, network access, and
|
||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||
@@ -75,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.26.6+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -181,8 +182,12 @@ zero providers add custom-openai-compatible \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
|
||||
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
|
||||
load a model and enable its local OpenAI-compatible API (default
|
||||
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
|
||||
loaded model ID in the add command. If no usable ID is discovered, load a model
|
||||
and retry. Model IDs requiring shell-specific quoting use interactive setup.
|
||||
|
||||
## Daily Use
|
||||
|
||||
@@ -212,6 +217,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/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 |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
@@ -271,6 +277,19 @@ zero sandbox grants list
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
`web_fetch` refuses loopback, private and other special-use addresses: it checks
|
||||
the URL before asking permission, resolves the host, and dials the address it
|
||||
validated so a name cannot resolve to something else in between.
|
||||
|
||||
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
|
||||
probe use it, and that last step changes: the proxy is dialed and the target
|
||||
hostname is sent to it, so the proxy decides which address the request actually
|
||||
reaches. The URL is still checked and resolved locally first, but a proxy that
|
||||
answers differently can reach a private service. A forward proxy already sees
|
||||
and can rewrite every request through it, so this is the trust you accept by
|
||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
||||
end.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
@@ -308,7 +327,8 @@ zero verify detect and run local verification checks
|
||||
zero changes inspect and commit local git changes
|
||||
zero usage token usage and estimated cost
|
||||
zero cron scheduled agent jobs
|
||||
zero update check for newer releases
|
||||
zero update --check check for newer releases
|
||||
zero upgrade download, verify, and install the latest release
|
||||
```
|
||||
|
||||
## Extending Zero
|
||||
@@ -368,9 +388,13 @@ go run ./cmd/zero-perf-bench
|
||||
|
||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||
session (background connection prewarm + request-prefix telemetry) for headless
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
||||
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
|
||||
variable unset and set.
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
|
||||
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
||||
with the variable unset and set.
|
||||
|
||||
Native ChatGPT Responses sessions are enabled by default. Set
|
||||
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
|
||||
transport.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
@@ -415,6 +439,8 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
## Community
|
||||
|
||||
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
|
||||
+26
-5
@@ -6,8 +6,9 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
@@ -22,7 +23,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
@@ -56,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.5+。
|
||||
源码构建需要 Go 1.26.6+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -113,7 +114,11 @@ export LONGCAT_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
|
||||
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
|
||||
|
||||
## 日常使用
|
||||
|
||||
@@ -141,6 +146,7 @@ zero
|
||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||
| `/image` | 为视觉模型附加图片 |
|
||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
|
||||
| `/compact`、`/context` | 管理上下文使用 |
|
||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||
@@ -226,7 +232,8 @@ zero verify 检测和运行本地验证检查
|
||||
zero changes 检查和提交本地 Git 变更
|
||||
zero usage Token 使用量和估算成本
|
||||
zero cron 定时智能体任务
|
||||
zero update 检查更新版本
|
||||
zero update --check 检查更新版本
|
||||
zero upgrade 下载、验证并安装最新版本
|
||||
```
|
||||
|
||||
## 外观和无障碍
|
||||
@@ -269,6 +276,20 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
- [性能](docs/PERFORMANCE.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),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@ Please include, where possible:
|
||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||
out of scope — but please report anything you are unsure about.
|
||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||
keyless web search, to the documented Firecrawl endpoint — see the README).
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
|
||||
@@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
|
||||
the model can inspect it and decide what to do next.
|
||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
||||
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||
resume/history; ACP currently persists conversational messages and streams
|
||||
tool/permission updates to the client.
|
||||
resume/history; ACP persists conversational messages and completed tool-call
|
||||
starts/results for load replay. Live streaming details and permission
|
||||
exchanges remain client notifications rather than durable history.
|
||||
|
||||
Tool success and failure are both informative context. A successful `read_file`
|
||||
result gives the model file contents; a failed `edit_file` result gives the model
|
||||
@@ -627,7 +628,12 @@ Core tool groups include:
|
||||
skills, `ask_user`, permission requests.
|
||||
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
||||
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
||||
- **Network tools**: `web_fetch` and optional web search backend.
|
||||
- **Network tools**: `web_fetch` and optional web search backend. `web_fetch`
|
||||
validates the URL before the permission prompt, refuses loopback/private/
|
||||
special-use addresses, and dials the address it resolved. With
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
|
||||
target itself, so destination enforcement ends at the proxy; see the note in
|
||||
the README.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
|
||||
+86
-10
@@ -1,14 +1,30 @@
|
||||
# Installing Zero
|
||||
|
||||
Zero is distributed as:
|
||||
Pick whichever fits how you already install things. None of these is the
|
||||
blessed one.
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.26.5+
|
||||
| Method | Command | Self-update |
|
||||
| --- | --- | --- |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
|
||||
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
|
||||
| Source | `go build -o zero ./cmd/zero` | rebuild |
|
||||
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
`zero upgrade` follows whatever owns the binary, with one exception it cannot
|
||||
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
|
||||
keg and names `brew upgrade zero`, and replaces the binary in place for
|
||||
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
|
||||
|
||||
@@ -111,6 +127,45 @@ Defaults:
|
||||
- Version: latest GitHub release
|
||||
- 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
|
||||
|
||||
```bash
|
||||
@@ -125,7 +180,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.26.6+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
@@ -234,10 +289,31 @@ directory.
|
||||
|
||||
## Updating
|
||||
|
||||
Check for a newer release:
|
||||
Check for a newer release, then download, verify, and install it in place:
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero upgrade
|
||||
```
|
||||
|
||||
Then reinstall with npm or rerun the install script for the version you want.
|
||||
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
||||
|
||||
`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,3 +138,45 @@ output or stop a still-running task by id.
|
||||
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
|
||||
`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.
|
||||
+85
-7
@@ -1,16 +1,23 @@
|
||||
# Update Flow
|
||||
|
||||
`zero update --check` checks the latest GitHub release and compares it with the
|
||||
local CLI version.
|
||||
local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`)
|
||||
downloads, verifies, and installs it.
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --target windows-x64
|
||||
|
||||
zero upgrade
|
||||
zero update --apply
|
||||
```
|
||||
|
||||
The command is intentionally check-only:
|
||||
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
|
||||
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
||||
|
||||
`--check` is check-only:
|
||||
|
||||
- It does not replace the running binary.
|
||||
- It exits with code `0` when the check succeeds, even when an update is
|
||||
@@ -19,14 +26,35 @@ The command is intentionally check-only:
|
||||
- `--json` prints the same result in a machine-readable format for scripts and
|
||||
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:
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--repo <owner/repo>` | Check another GitHub repository. |
|
||||
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
||||
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
||||
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
||||
| `--timeout <duration>` | Override the default release check timeout. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target (`--check` only). |
|
||||
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
||||
@@ -40,5 +68,55 @@ Endpoint resolution order:
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
|
||||
before reinstalling.
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
instead of 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,25 +1,28 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
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/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
@@ -62,9 +64,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
+673
-73
File diff suppressed because it is too large.
Load diff
+2099
-17
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,217 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+43
-10
@@ -89,6 +89,13 @@ type Conn struct {
|
||||
|
||||
handlers map[string]HandlerFunc
|
||||
notifiers map[string]NotifyFunc
|
||||
// Notifications for one method are an ordered stream: session/update in
|
||||
// particular is stateful, so dispatching every frame in an unrelated
|
||||
// goroutine can make a later chunk overtake an earlier one. Different
|
||||
// methods retain independent tails, keeping session/cancel responsive while
|
||||
// an update handler is busy.
|
||||
notifyMu sync.Mutex
|
||||
notifyTails map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
@@ -106,11 +113,12 @@ type Conn struct {
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
notifyTails: make(map[string]chan struct{}),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}(msg)
|
||||
case msg.isNotify():
|
||||
if fn := c.notifiers[msg.Method]; fn != nil {
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
fn(ctx, m.Params)
|
||||
}(msg)
|
||||
c.dispatchNotification(ctx, msg, fn)
|
||||
}
|
||||
default:
|
||||
// Malformed frame; reply only if we can identify a request id.
|
||||
@@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchNotification preserves wire order within one method without making
|
||||
// unrelated notification methods wait for each other. The read loop installs
|
||||
// each tail before starting its goroutine, so goroutine scheduling cannot
|
||||
// reorder the chain it observes.
|
||||
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
|
||||
c.notifyMu.Lock()
|
||||
previous := c.notifyTails[msg.Method]
|
||||
done := make(chan struct{})
|
||||
c.notifyTails[msg.Method] = done
|
||||
c.notifyMu.Unlock()
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
defer func() {
|
||||
close(done)
|
||||
c.notifyMu.Lock()
|
||||
if c.notifyTails[msg.Method] == done {
|
||||
delete(c.notifyTails, msg.Method)
|
||||
}
|
||||
c.notifyMu.Unlock()
|
||||
}()
|
||||
if previous != nil {
|
||||
<-previous
|
||||
}
|
||||
fn(ctx, msg.Params)
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||
fn := c.handlers[msg.Method]
|
||||
if fn == nil {
|
||||
|
||||
@@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
delivered := make(chan int, 2)
|
||||
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
|
||||
var in struct{ Sequence int }
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if in.Sequence == 1 {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
}
|
||||
delivered <- in.Sequence
|
||||
})
|
||||
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
|
||||
t.Fatalf("first notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-firstStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first notification did not start")
|
||||
}
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
|
||||
t.Fatalf("second notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case sequence := <-delivered:
|
||||
t.Fatalf("notification %d overtook the blocked first notification", sequence)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
close(releaseFirst)
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-delivered:
|
||||
if got != want {
|
||||
t.Fatalf("notification order = ...,%d; want %d", got, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("notification %d was not delivered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
updateStarted := make(chan struct{})
|
||||
releaseUpdate := make(chan struct{})
|
||||
cancelDelivered := make(chan struct{})
|
||||
b.HandleNotify("update", func(context.Context, json.RawMessage) {
|
||||
close(updateStarted)
|
||||
<-releaseUpdate
|
||||
})
|
||||
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
|
||||
close(cancelDelivered)
|
||||
})
|
||||
|
||||
if err := a.Notify("update", nil); err != nil {
|
||||
t.Fatalf("update notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("update notification did not start")
|
||||
}
|
||||
if err := a.Notify("cancel", nil); err != nil {
|
||||
t.Fatalf("cancel notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-cancelDelivered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("different notification method was blocked behind update")
|
||||
}
|
||||
close(releaseUpdate)
|
||||
}
|
||||
|
||||
func TestConnMethodNotFound(t *testing.T) {
|
||||
a, _, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
+80
-23
@@ -15,17 +15,10 @@ import (
|
||||
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
||||
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
||||
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
actions := req.AvailableDecisions
|
||||
if len(actions) == 0 {
|
||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
||||
actions = []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
}
|
||||
actions := offeredDecisions(req)
|
||||
options := make([]PermissionOption, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
kind, name := optionKindFor(action)
|
||||
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
||||
if kind == "" {
|
||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||
}
|
||||
@@ -38,21 +31,61 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
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
|
||||
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||
// outcome rather than an option (cancel).
|
||||
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
//
|
||||
// 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 {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
case agent.PermissionDecisionAllow:
|
||||
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:
|
||||
return PermAllowAlways, "Allow for this session"
|
||||
case agent.PermissionDecisionAllowPrefix:
|
||||
return PermAllowAlways, "Allow this command for the session"
|
||||
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
|
||||
case agent.PermissionDecisionAlwaysAllow:
|
||||
return PermAllowAlways, "Always allow"
|
||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||
return PermAllowAlways, "Always allow this command"
|
||||
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
|
||||
case agent.PermissionDecisionDeny:
|
||||
return PermRejectOnce, "Reject"
|
||||
case agent.PermissionDecisionCancel:
|
||||
@@ -64,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
|
||||
// decisionFromOutcome maps the client's permission outcome back to a ZERO
|
||||
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
||||
// action verbatim (validated against what was offered); anything unrecognized
|
||||
// fails closed to deny.
|
||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
||||
// action verbatim (validated against what was actually sent); anything
|
||||
// unrecognized fails closed to deny.
|
||||
//
|
||||
// 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 {
|
||||
case OutcomeCancelled:
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
||||
case OutcomeSelected:
|
||||
action := agent.PermissionDecisionAction(outcome.OptionID)
|
||||
// Bind to what was actually offered for THIS call: a client must not be able
|
||||
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
||||
// presented. Anything not offered fails closed to deny.
|
||||
if actionOffered(action, offered) {
|
||||
return agent.PermissionDecision{Action: action}
|
||||
if actionOffered(outcome.OptionID, offered) {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||
default:
|
||||
@@ -84,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
|
||||
}
|
||||
}
|
||||
|
||||
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
||||
for _, a := range offered {
|
||||
if a == action {
|
||||
func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||
for _, option := range offered {
|
||||
if option.OptionID == optionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -97,13 +142,15 @@ func actionOffered(action agent.PermissionDecisionAction, offered []agent.Permis
|
||||
// session/request_permission request from a ZERO permission request.
|
||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||
args := marshalArgs(req.Args)
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
ToolCallID: req.ToolCallID,
|
||||
Title: toolTitle(req.ToolName, string(args)),
|
||||
Kind: toolKindFor(req.ToolName),
|
||||
Status: ToolStatusPending,
|
||||
RawInput: rawInputBytes(args),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, req.ToolName)
|
||||
return upd
|
||||
}
|
||||
|
||||
func marshalArgs(args map[string]any) []byte {
|
||||
@@ -123,3 +170,13 @@ func rawInputBytes(data []byte) json.RawMessage {
|
||||
}
|
||||
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)"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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,11 +39,14 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecisionFromOutcome(t *testing.T) {
|
||||
offered := []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
offered := buildPermissionOptions(agent.PermissionRequest{
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
})
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
||||
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
||||
}
|
||||
@@ -79,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
|
||||
t.Error("expected rawInput from args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
|
||||
call := permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "browser-1",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
})
|
||||
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
|
||||
t.Fatalf("browser descriptor = %#v", got)
|
||||
}
|
||||
if call.Title != "browser connect" {
|
||||
t.Fatalf("title = %q", call.Title)
|
||||
}
|
||||
}
|
||||
+170
-6
@@ -2,7 +2,10 @@ package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -17,6 +20,14 @@ func agentMessageChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
func replayMessageChunk(role, messageID, text string) ContentChunk {
|
||||
update := UpdateAgentMessageChunk
|
||||
if role == "user" {
|
||||
update = UpdateUserMessageChunk
|
||||
}
|
||||
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
|
||||
}
|
||||
|
||||
func agentThoughtChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
@@ -44,12 +55,125 @@ func toolKindFor(name string) string {
|
||||
|
||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||
func toolTitle(name, rawArgs string) string {
|
||||
if browser, ok := browserToolDetails(name); ok {
|
||||
return browserToolTitle(browser.Command, rawArgs)
|
||||
}
|
||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||
return name + " " + hint
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// browserToolDetails identifies ZERO's local browser helpers without treating
|
||||
// similarly named MCP tools as browser automation. The descriptor intentionally
|
||||
// contains no request data: ACP tool input is already protocol-visible, but a
|
||||
// durable UI must not need to retain text, local CDP targets, or full URLs just
|
||||
// to recognise the browser operation.
|
||||
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
|
||||
const prefix = "browser_"
|
||||
command, ok := strings.CutPrefix(name, prefix)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch command {
|
||||
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
|
||||
return &BrowserToolDetails{Version: 1, Command: command}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
|
||||
|
||||
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
|
||||
// extension channel. Keeping this in one helper prevents start, result, and
|
||||
// permission payloads from drifting onto different wire shapes.
|
||||
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
|
||||
browser, ok := browserToolDetails(name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(browser)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
|
||||
}
|
||||
|
||||
// browserToolTitle avoids putting browser_type text, an attached DevTools
|
||||
// endpoint, or a URL query/fragment in a tool-card title. Those values can
|
||||
// carry credentials or session data; the UI only needs the operation and, for
|
||||
// navigation, a human-recognisable origin.
|
||||
func browserToolTitle(command, rawArgs string) string {
|
||||
switch command {
|
||||
case "action":
|
||||
action, ok := exactJSONStringArg(rawArgs, "command")
|
||||
if !ok {
|
||||
return "browser action"
|
||||
}
|
||||
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
|
||||
return "browser action " + action
|
||||
}
|
||||
return "browser action"
|
||||
case "open":
|
||||
rawURL, ok := exactJSONStringArg(rawArgs, "url")
|
||||
if !ok {
|
||||
return "browser open"
|
||||
}
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
return "browser open"
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "browser open"
|
||||
}
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
if !browserTitleTextSafe(origin) {
|
||||
return "browser open"
|
||||
}
|
||||
return "browser open " + truncateHint(origin)
|
||||
default:
|
||||
return "browser " + command
|
||||
}
|
||||
}
|
||||
|
||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: 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
|
||||
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
||||
func primaryArgHint(rawArgs string) string {
|
||||
@@ -89,7 +213,7 @@ func rawInput(args string) json.RawMessage {
|
||||
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
||||
// update (status in_progress — ZERO executes immediately after advertising).
|
||||
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCall,
|
||||
ToolCallID: call.ID,
|
||||
Title: toolTitle(call.Name, call.Arguments),
|
||||
@@ -97,6 +221,8 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
Status: ToolStatusInProgress,
|
||||
RawInput: rawInput(call.Arguments),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, call.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||
@@ -116,26 +242,64 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||
upd.Locations = locs
|
||||
}
|
||||
attachBrowserToolDetails(&upd, result.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
|
||||
text := strings.TrimRight(result.Output, "\n")
|
||||
if text == "" {
|
||||
text = result.Display.Summary
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||
content = append(content, ToolContent(TextBlock(text)))
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
|
||||
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
|
||||
for _, diff := range diffs {
|
||||
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
|
||||
continue
|
||||
}
|
||||
var oldText *string
|
||||
if diff.OldExists {
|
||||
old := diff.OldText
|
||||
oldText = &old
|
||||
}
|
||||
var newText *string
|
||||
if diff.NewExists {
|
||||
updated := diff.NewText
|
||||
newText = &updated
|
||||
}
|
||||
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||
for _, f := range result.ChangedFiles {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
for _, diff := range result.FileDiffs {
|
||||
path := diff.Path
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
locs = append(locs, ToolCallLocation{Path: path})
|
||||
}
|
||||
// FileDiff.Path is canonical absolute path data while ChangedFiles is
|
||||
// normally workspace-relative. Without the trusted workspace root these
|
||||
// coordinate systems cannot be correlated safely: a suffix match would let
|
||||
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
|
||||
// shared seen set deduplicates only identities already exactly comparable.
|
||||
for _, f := range result.ChangedFiles {
|
||||
if f == "" || seen[f] {
|
||||
continue
|
||||
}
|
||||
seen[f] = true
|
||||
locs = append(locs, ToolCallLocation{Path: f})
|
||||
}
|
||||
return locs
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
|
||||
t.Helper()
|
||||
raw, ok := update.Meta[zeroBrowserMetaKey]
|
||||
if !ok {
|
||||
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
|
||||
}
|
||||
var details BrowserToolDetails
|
||||
if err := json.Unmarshal(raw, &details); err != nil {
|
||||
t.Fatalf("decode browser metadata: %v", err)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||
m := agentMessageChunk("hello")
|
||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||
@@ -56,6 +72,217 @@ 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) {
|
||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||
if upd.SessionUpdate != UpdateToolCall {
|
||||
@@ -74,21 +301,26 @@ func TestToolCallStart(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToolCallResult(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
ok := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "tc1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "applied\n",
|
||||
ChangedFiles: []string{"a.go", ""},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
|
||||
})
|
||||
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
||||
t.Fatalf("unexpected ok result: %+v", ok)
|
||||
}
|
||||
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
t.Fatalf("unexpected content: %+v", ok.Content)
|
||||
}
|
||||
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
|
||||
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
|
||||
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
|
||||
t.Fatalf("unexpected diff content: %+v", diff)
|
||||
}
|
||||
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
|
||||
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
|
||||
}
|
||||
|
||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||
@@ -97,6 +329,190 @@ func TestToolCallResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
||||
content := appendToolResultDiffs(nil, []tools.FileDiff{
|
||||
{Path: path, OldExists: false, NewExists: true, NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
|
||||
})
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("diff content = %#v", content)
|
||||
}
|
||||
for index, diff := range content {
|
||||
encoded, err := json.Marshal(diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wire["path"] != path {
|
||||
t.Fatalf("wire diff %d = %s", index, encoded)
|
||||
}
|
||||
switch index {
|
||||
case 0:
|
||||
oldText, present := wire["oldText"]
|
||||
if !present || oldText != nil || wire["newText"] != "" {
|
||||
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
|
||||
}
|
||||
case 1:
|
||||
if wire["oldText"] != "before" || wire["newText"] != "" {
|
||||
t.Fatalf("empty replacement diff = %s", encoded)
|
||||
}
|
||||
case 2:
|
||||
newText, present := wire["newText"]
|
||||
if wire["oldText"] != "before" || !present || newText != nil {
|
||||
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rootPath := filepath.Join(root, "a.go")
|
||||
nestedPath := filepath.Join(root, "sub", "a.go")
|
||||
diff := func(path string) tools.FileDiff {
|
||||
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
diffs []tools.FileDiff
|
||||
want []string
|
||||
}{
|
||||
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
|
||||
FileDiffs: tc.diffs,
|
||||
})
|
||||
if len(locations) != len(tc.want) {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
for index := range tc.want {
|
||||
if locations[index].Path != tc.want[index] {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
|
||||
relativePath := " report.txt "
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
|
||||
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{path, path},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
|
||||
})
|
||||
if len(locations) != 1 || locations[0].Path != path {
|
||||
t.Fatalf("exact duplicate locations = %#v", locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
|
||||
relativePath := "deleted.go"
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
|
||||
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("deleted file locations = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
path := filepath.Join(t.TempDir(), "secret.txt")
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
|
||||
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
|
||||
}}})
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
|
||||
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
|
||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{
|
||||
ChangedFiles: []string{"credentials.txt"},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
|
||||
}},
|
||||
})
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "call", Status: tools.StatusOK,
|
||||
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
|
||||
})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
|
||||
t.Fatalf("ACP path fallback = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
for name, separator := range map[string]string{
|
||||
"combining grapheme joiner": "\u034f",
|
||||
"variation selector": "\ufe0f",
|
||||
} {
|
||||
for _, side := range []string{"old", "new"} {
|
||||
t.Run(name+" "+side, func(t *testing.T) {
|
||||
obfuscated := secret[:20] + separator + secret[20:]
|
||||
diff := tools.FileDiff{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "safe old", NewText: "safe new",
|
||||
}
|
||||
if side == "old" {
|
||||
diff.OldText = obfuscated
|
||||
} else {
|
||||
diff.NewText = obfuscated
|
||||
}
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
|
||||
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
|
||||
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
|
||||
}
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||
upd := planUpdate([]tools.PlanItem{
|
||||
{Content: "step a", Status: "completed"},
|
||||
|
||||
+93
-8
@@ -12,6 +12,8 @@ const (
|
||||
MethodAuthenticate = "authenticate"
|
||||
MethodSessionNew = "session/new"
|
||||
MethodSessionLoad = "session/load"
|
||||
MethodSessionList = "session/list"
|
||||
MethodSessionResume = "session/resume"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel" // notification
|
||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||
@@ -62,8 +64,16 @@ type PromptCapabilities struct {
|
||||
}
|
||||
|
||||
type AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
|
||||
}
|
||||
|
||||
// Empty capability objects are presence flags in ACP v1. Pointers preserve
|
||||
// the wire distinction between an advertised `{}` and an omitted capability.
|
||||
type SessionCapabilities struct {
|
||||
List *struct{} `json:"list,omitempty"`
|
||||
Resume *struct{} `json:"resume,omitempty"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
@@ -117,9 +127,14 @@ type McpServer struct {
|
||||
}
|
||||
|
||||
type NewSessionParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
// AdditionalDirectories is declared by the protocol and consumed nowhere in
|
||||
// this repo yet. When it is wired up it must go through requestedWorkspace
|
||||
// like Cwd does: these are client-supplied paths with no absoluteness rule of
|
||||
// their own, which is the same shape as the resume-cwd hole that made
|
||||
// {"sessionId":"known"} activate a session against this process's directory.
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionResult struct {
|
||||
@@ -140,6 +155,39 @@ type LoadSessionResult struct {
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
// ListSessionsParams and the following types implement ACP v1 session/list.
|
||||
// Transcript contents stay behind session/load; this method returns metadata
|
||||
// only and leaves the optional pagination cursor opaque.
|
||||
type ListSessionsParams struct {
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfoMeta struct {
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Meta *SessionInfoMeta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
type ListSessionsResult struct {
|
||||
Sessions []SessionInfo `json:"sessions"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// session/resume takes session/load's wire shape, so its cwd is a plain string
|
||||
// too and an omitted one decodes exactly like an empty one. Sharing the type is
|
||||
// safe only because activatePersistedSession requires an absolute cwd from the
|
||||
// request itself — nothing downstream may supply a default for either method.
|
||||
type ResumeSessionParams = LoadSessionParams
|
||||
type ResumeSessionResult = LoadSessionResult
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
type PromptParams struct {
|
||||
@@ -174,6 +222,7 @@ type SessionNotification struct {
|
||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||
type ContentChunk struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Content ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
@@ -210,6 +259,21 @@ type ToolCallUpdate struct {
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
|
||||
// namespaced key so protocol-shaped clients can preserve them while decoding
|
||||
// and re-encoding a tool call.
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserToolDetails identifies the browser helper operation behind a tool
|
||||
// call. Version is the schema version for this optional ZERO extension;
|
||||
// Command is one of install, launch, connect, open, snapshot, click, type,
|
||||
// press, or action. Future fields must remain display-safe and must not
|
||||
// include browser profile data, cookies, typed text, URL paths/queries, or
|
||||
// DevTools endpoints.
|
||||
type BrowserToolDetails struct {
|
||||
Version int `json:"version"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||
@@ -220,9 +284,30 @@ type ToolCallContent struct {
|
||||
// type == "content"
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
// type == "diff"
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText string `json:"oldText,omitempty"`
|
||||
NewText string `json:"newText,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText *string `json:"oldText,omitempty"`
|
||||
NewText *string `json:"newText,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON preserves ACP's discriminated content union. A diff always has
|
||||
// path, while nullable oldText/newText distinguish creation, deletion, and an
|
||||
// existing file replaced with empty content. Other content variants omit all
|
||||
// diff fields rather than serializing irrelevant nulls.
|
||||
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
|
||||
if content.Type == "diff" {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
OldText *string `json:"oldText"`
|
||||
NewText *string `json:"newText"`
|
||||
}{
|
||||
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
}{Type: content.Type, Content: content.Content})
|
||||
}
|
||||
|
||||
func ToolContent(block ContentBlock) ToolCallContent {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -111,7 +112,11 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
testutil.WaitFor(t, "worker idle", func() bool {
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
return diagnostics.working == nil
|
||||
})
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
@@ -123,23 +128,6 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
@@ -99,6 +99,43 @@ 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) {
|
||||
cases := []string{
|
||||
"cat < in > out",
|
||||
|
||||
+257
-29
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -38,8 +40,9 @@ const compactionTriggerRatio = 0.7
|
||||
// transcript (and so tests can assert on it).
|
||||
const summaryLabel = "[Summary of earlier conversation]"
|
||||
|
||||
// summaryInstructions is the system prompt handed to the summarizer model.
|
||||
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
||||
// and manually requested compaction summarizers.
|
||||
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; " +
|
||||
"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. " +
|
||||
@@ -75,6 +78,12 @@ type CompactionResult struct {
|
||||
// summary message also includes summaryLabel and any preserved structured
|
||||
// state needed by later compactions.
|
||||
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 bool
|
||||
}
|
||||
@@ -137,11 +146,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += ApproxTextTokens(string(tool.Type))
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
if tool.Format != nil {
|
||||
total += ApproxTextTokens(tool.Format.Type)
|
||||
total += ApproxTextTokens(tool.Format.Syntax)
|
||||
total += ApproxTextTokens(tool.Format.Definition)
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
}
|
||||
return total
|
||||
@@ -216,11 +231,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, err := opts.Summarize(middle)
|
||||
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
||||
if err != nil {
|
||||
return CompactionResult{}, err
|
||||
}
|
||||
summary = strings.TrimSpace(summary)
|
||||
summary := summaryResult.SummaryText
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
@@ -238,10 +253,53 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
RemovedCount: len(middle),
|
||||
PreservedCount: len(messages) - len(middle),
|
||||
SummaryText: summary,
|
||||
ProjectedChars: summaryResult.ProjectedChars,
|
||||
Truncated: summaryResult.Truncated,
|
||||
Compacted: true,
|
||||
}, 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
|
||||
// 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
|
||||
@@ -331,6 +389,8 @@ type compactionState struct {
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
planner *contextPlanner
|
||||
trace *trace.Recorder
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -338,6 +398,27 @@ type compactionState struct {
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
|
||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
||||
// memoizes the built provider and summarizerResolved records that the
|
||||
// factory ran (it may legitimately return no provider: the main model is
|
||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
||||
// build or call fails, the run stays on the main provider until the next
|
||||
// model switch — a misconfigured cheap model must never break (or
|
||||
// repeatedly delay) compaction.
|
||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
summarizerProvider Provider
|
||||
summarizerResolved bool
|
||||
summarizerBroken bool
|
||||
// window is the context window the threshold was derived from. The
|
||||
// reactive free-prune stage compares against it (the limit the provider
|
||||
// enforces), not against the 70% proactive threshold — a pruned history
|
||||
// between the two would still succeed on retry.
|
||||
window int
|
||||
// mainModel is the model the run currently streams on; the summarizer
|
||||
// choice is made against it and remade after a mid-run switch.
|
||||
mainModel string
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
@@ -372,15 +453,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
window: options.ContextWindow,
|
||||
mainModel: options.Model,
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
summarizerFactory: options.Summarizer,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
||||
// switch. The low-water mark is reset: it was measured against the old
|
||||
// threshold, and keeping it could suppress a compaction the smaller window
|
||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
||||
func (state *compactionState) updateWindow(window int) {
|
||||
if window <= 0 {
|
||||
return
|
||||
}
|
||||
state.enabled = true
|
||||
state.window = window
|
||||
state.threshold = compactionThreshold(window)
|
||||
state.lowWaterMark = 0
|
||||
if state.planner != nil {
|
||||
state.planner.config.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// switchModel records a mid-run model switch: the window is re-derived
|
||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
||||
// the next compaction re-resolves it against the new main model. A run that
|
||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
||||
// dedicated summarizer exists for.
|
||||
func (state *compactionState) switchModel(modelID string, window int) {
|
||||
state.mainModel = modelID
|
||||
state.updateWindow(window)
|
||||
state.summarizerProvider = nil
|
||||
state.summarizerResolved = false
|
||||
state.summarizerBroken = false
|
||||
}
|
||||
|
||||
// summarizeProvider returns the provider summarization calls should use and
|
||||
// whether it is the dedicated summarizer: that one when it applies to the
|
||||
// current main model and is healthy, else the main provider. The factory runs
|
||||
// at most once per main model; a build error marks the summarizer broken for
|
||||
// that model. Callers branch on the boolean rather than comparing Provider
|
||||
// values — interface comparison panics on a non-comparable dynamic type.
|
||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
||||
return main, false
|
||||
}
|
||||
if !state.summarizerResolved {
|
||||
state.summarizerResolved = true
|
||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
||||
if err != nil {
|
||||
state.summarizerBroken = true
|
||||
return main, false
|
||||
}
|
||||
state.summarizerProvider = built
|
||||
}
|
||||
if state.summarizerProvider == nil {
|
||||
return main, false
|
||||
}
|
||||
return state.summarizerProvider, true
|
||||
}
|
||||
|
||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
||||
// against the window itself, with a small margin for estimate error, so a
|
||||
// history pruned to, say, 85% of the window takes the free retry.
|
||||
func (state *compactionState) fitsWindow(size int) bool {
|
||||
if state.window <= 0 {
|
||||
return size <= state.threshold
|
||||
}
|
||||
return size <= state.window-state.window/20
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
||||
// when the history is under threshold, or when the history has not grown past
|
||||
@@ -432,7 +586,7 @@ func (state *compactionState) maybeCompact(
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -474,16 +628,40 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
||||
// often be fixed for free by dropping superseded reads and pruning old
|
||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
||||
// prune). The free retry is taken only when the pruned history fits the
|
||||
// window the provider enforces — otherwise a retry would fail the same
|
||||
// way, so fall through to the paid summarizer in this same call.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
prunedAny := false
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if prunedAny {
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if state.fitsWindow(size) {
|
||||
state.lowWaterMark = size
|
||||
return messages, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if state.reactiveAttempted {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
@@ -517,9 +695,22 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
||||
// call fails the summarizer is marked broken for the current model and the
|
||||
// same slice is retried once on the main provider, so the caller sees at most
|
||||
// one (main-provider-quality) error.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
if err == nil || !dedicated {
|
||||
return summary, err
|
||||
}
|
||||
state.summarizerBroken = true
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +722,11 @@ func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage
|
||||
// Non-context-limit errors (and a single message that still won't fit) surface
|
||||
// to the caller unchanged.
|
||||
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
||||
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
|
||||
}
|
||||
|
||||
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 {
|
||||
return summary, nil
|
||||
}
|
||||
@@ -540,11 +735,11 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
}
|
||||
|
||||
mid := len(messages) / 2
|
||||
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
||||
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
||||
if leftErr != nil {
|
||||
return "", leftErr
|
||||
}
|
||||
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
||||
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
||||
if rightErr != nil {
|
||||
return "", rightErr
|
||||
}
|
||||
@@ -557,7 +752,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||
}, onUsage)
|
||||
}, onUsage, planner, recorder)
|
||||
if reduceErr != nil {
|
||||
if isContextLimitError(reduceErr.Error()) {
|
||||
// Even the two combined partials don't fit (extreme): fall back to the
|
||||
@@ -573,15 +768,15 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
}
|
||||
|
||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{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.
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
requestMessages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
||||
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
||||
recordContextPlanTrace(recorder, plan)
|
||||
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -598,8 +793,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
||||
// search results), yet the summary instructions only need their gist — the
|
||||
// model that ACTED on the full output already turned it into decisions the
|
||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
||||
const summaryToolResultClamp = 2000
|
||||
|
||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
||||
// a write_file call carries the whole file body in its arguments.
|
||||
const summaryToolArgsClamp = 500
|
||||
|
||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
||||
func clampForSummary(text string, max int) string {
|
||||
if max <= 0 || len(text) <= max {
|
||||
return text
|
||||
}
|
||||
head := max * 3 / 4
|
||||
tail := max - head
|
||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
||||
head--
|
||||
}
|
||||
start := len(text) - tail
|
||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
||||
start++
|
||||
}
|
||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a plain-text transcript for the
|
||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
||||
// above): the summary needs what was done and decided, not the raw payloads.
|
||||
func renderTranscript(messages []zeroruntime.Message) string {
|
||||
lines := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
@@ -609,13 +837,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
||||
if len(message.ToolCalls) > 0 {
|
||||
calls := make([]string, 0, len(message.ToolCalls))
|
||||
for _, call := range message.ToolCalls {
|
||||
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
// the proactive threshold for a 1000-token window.
|
||||
func compactableHistory() []zeroruntime.Message {
|
||||
return []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
||||
}
|
||||
}
|
||||
|
||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
||||
// configured — the main provider sees no summarize traffic at all.
|
||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
main := &mockProvider{}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
||||
|
||||
if len(cheap.requests) == 0 {
|
||||
t.Fatal("dedicated summarizer never received the summarize call")
|
||||
}
|
||||
if len(main.requests) != 0 {
|
||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
||||
t.Fatal("compaction did not shrink the history")
|
||||
}
|
||||
found := false
|
||||
for _, message := range compacted {
|
||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
||||
|
||||
summary, err := summarize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
||||
}
|
||||
if summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
||||
}
|
||||
if !state.summarizerBroken {
|
||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
||||
}
|
||||
|
||||
// Second summarization: straight to main, no cheap retry.
|
||||
if _, err := summarize(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
||||
}
|
||||
if len(main.requests) != 2 {
|
||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
||||
// main provider used, factory not retried.
|
||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return nil, errors.New("unknown model")
|
||||
},
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
|
||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool payloads are clamped in the summarizer transcript: results to
|
||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
||||
bigResult := strings.Repeat("r", 50_000)
|
||||
bigArgs := strings.Repeat("w", 20_000)
|
||||
transcript := renderTranscript([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
||||
})
|
||||
|
||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
||||
}
|
||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
||||
t.Fatal("clamped transcript must carry the omission marker")
|
||||
}
|
||||
if !strings.Contains(transcript, "keep going") {
|
||||
t.Fatal("non-tool content must survive unclamped")
|
||||
}
|
||||
// Head AND tail of the clamped payloads survive.
|
||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
||||
// the older ones are prunable.
|
||||
func bigToolHistory(n int) []zeroruntime.Message {
|
||||
bigBody := strings.Repeat("x", 160_000)
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
||||
for i := 0; i < n; i++ {
|
||||
id := string(rune('a' + i))
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
||||
)
|
||||
}
|
||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
||||
}
|
||||
|
||||
func summaryProvider(text string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
// A context-limit error is first answered with the free prune stage; when the
|
||||
// pruned history fits the window the retry is free (no summarizer call) and
|
||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
||||
// when a later error finds nothing left to prune.
|
||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("prunable history that fits must produce a free retry")
|
||||
}
|
||||
if len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
|
||||
// Second context-limit error on the pruned history: nothing meaningfully
|
||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("second recover must compact and retry")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("second recover must reach the summarizer")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("a paid recover must consume the reactive budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When pruning reclaims something but the pruned history still exceeds the
|
||||
// window, recover falls through to the paid summarizer in the SAME call
|
||||
// instead of spending a retry that would fail the same way.
|
||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// A 1,000-token window: even the protected trailing output overflows it.
|
||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
||||
t.Fatal("recover did not shrink the history")
|
||||
}
|
||||
}
|
||||
|
||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
||||
// reactive compaction, a later context-limit error still gets a free retry
|
||||
// when pruning makes the history fit.
|
||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
state.reactiveAttempted = true
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
// With nothing left to prune and the budget spent, the caller keeps its
|
||||
// original error — recover neither loops nor summarizes.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil || retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
||||
}
|
||||
}
|
||||
|
||||
// The free-retry gate compares against the window the provider enforces, not
|
||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
||||
// takes the free retry.
|
||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
messages := bigToolHistory(5)
|
||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
||||
prunedTokens := estimateTokens(pruned)
|
||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
||||
window := prunedTokens * 100 / 80
|
||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
||||
if prunedTokens <= state.threshold {
|
||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
||||
}
|
||||
|
||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
||||
// retried for the new model.
|
||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
||||
cheap := summaryProvider("CHEAP SUMMARY")
|
||||
main := summaryProvider("MAIN SUMMARY")
|
||||
var factoryModels []string
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Model: "cheap-model",
|
||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
||||
factoryModels = append(factoryModels, mainModelID)
|
||||
if mainModelID == "cheap-model" {
|
||||
return nil, nil // main already is the cheap model
|
||||
}
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("on the cheap model the main provider must summarize")
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
state.switchModel("big-model", 400_000)
|
||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
||||
}
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
||||
}
|
||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
// A broken summarizer is retried after the next switch.
|
||||
state.summarizerBroken = true
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("a broken summarizer must fall back to main")
|
||||
}
|
||||
state.switchModel("other-model", 0)
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionUpdateWindow(t *testing.T) {
|
||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
||||
state.lowWaterMark = 50_000
|
||||
before := state.threshold
|
||||
|
||||
state.updateWindow(0) // unknown model: unchanged
|
||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
||||
t.Fatal("updateWindow(0) must be a no-op")
|
||||
}
|
||||
|
||||
state.updateWindow(10_000)
|
||||
if state.threshold >= before {
|
||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
||||
}
|
||||
if state.lowWaterMark != 0 {
|
||||
t.Fatal("low-water mark must reset on a window change")
|
||||
}
|
||||
if !state.enabled {
|
||||
t.Fatal("a positive window must keep compaction enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
||||
// context window via ContextWindowFor, visible in the OnContext report.
|
||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "big-model"})
|
||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
var windows []int
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
ContextWindow: 100_000,
|
||||
Model: "small-model",
|
||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
if modelID == "big-model" {
|
||||
return 400_000
|
||||
}
|
||||
return 0
|
||||
},
|
||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(windows) < 2 {
|
||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
||||
}
|
||||
if windows[0] != 100_000 {
|
||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
||||
}
|
||||
if windows[len(windows)-1] != 400_000 {
|
||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
||||
// values holding it would panic; the summarizer path must never compare them.
|
||||
type nonComparableProvider struct {
|
||||
inner *mockProvider
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return p.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
||||
}, nil)
|
||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
||||
}
|
||||
// And with no dedicated summarizer at all the closure still works.
|
||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
||||
if result.SummaryText != "manual summary" {
|
||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||
}
|
||||
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
|
||||
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(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 {
|
||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||
@@ -96,11 +102,46 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
|
||||
if 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) {
|
||||
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) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
|
||||
@@ -102,7 +102,7 @@ type skillEntry struct {
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// recentEdits returns files mutated by editing calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
@@ -113,6 +113,7 @@ type skillEntry struct {
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
noteByPath := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
@@ -129,12 +130,24 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
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)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
@@ -425,15 +438,20 @@ type preservedState struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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 {
|
||||
@@ -468,7 +486,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
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
|
||||
@@ -484,6 +507,13 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
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 preserved by an earlier compaction.
|
||||
@@ -517,6 +547,95 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,9 @@ 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,
|
||||
@@ -80,6 +84,69 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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,11 +20,15 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
{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.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)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
if len(edits) != 3 {
|
||||
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
@@ -32,6 +36,9 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
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])
|
||||
}
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -58,12 +59,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
||||
if last.Content != "most recent question" {
|
||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||
}
|
||||
// The summarized middle excludes system and preserved suffix.
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
||||
// The summarizer receives a compact semantic projection of the middle rather
|
||||
// than reconstructible raw tool output.
|
||||
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
|
||||
t.Fatalf("expected one projected summary input, got %#v", captured)
|
||||
}
|
||||
if captured[0].Content != "first question" {
|
||||
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
||||
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
|
||||
if !strings.Contains(captured[0].Content, want) {
|
||||
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
|
||||
}
|
||||
}
|
||||
// Compaction must shrink the conversation.
|
||||
if estimateTokens(result) >= estimateTokens(messages) {
|
||||
@@ -271,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
||||
if estimateToolDefTokens(one) <= 0 {
|
||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
||||
}
|
||||
typed := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
typed[0].Type = zeroruntime.ToolDefinitionFreeform
|
||||
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
|
||||
t.Fatal("a non-empty tool type must increase the estimated request cost")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
|
||||
} {
|
||||
t.Run("format "+test.name, func(t *testing.T) {
|
||||
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
formatted[0].Format = &test.format
|
||||
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
|
||||
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||
Name: "write_file",
|
||||
Description: "Write contents to a file in the workspace.",
|
||||
@@ -425,6 +450,7 @@ type reactiveProvider struct {
|
||||
summarizeCalls int
|
||||
turnRequests int
|
||||
failedOnce bool
|
||||
connectError bool
|
||||
bigText string
|
||||
finalText string
|
||||
}
|
||||
@@ -446,6 +472,9 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
||||
case provider.turnRequests == 2 && !provider.failedOnce:
|
||||
provider.failedOnce = true
|
||||
if provider.connectError {
|
||||
return nil, errors.New("context_length_exceeded")
|
||||
}
|
||||
return streamEvents([]zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
||||
}), nil
|
||||
@@ -454,6 +483,39 @@ 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) {
|
||||
provider := &reactiveProvider{
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
@@ -465,6 +527,8 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -473,6 +537,10 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
||||
@@ -483,6 +551,25 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
if provider.summarizeCalls == 0 {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,24 @@ type ContextBreakdown struct {
|
||||
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
||||
ContextWindow int // model context window; 0 when 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
|
||||
@@ -41,6 +59,24 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
ContextWindow: contextWindow,
|
||||
}
|
||||
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 {
|
||||
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
|
||||
if 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) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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, ",")
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -797,3 +797,98 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
var switchedTo string
|
||||
switchCount := 0
|
||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||
switchCount++
|
||||
switchedTo = modelID
|
||||
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
if len(firstProvider.requests) != 1 {
|
||||
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 {
|
||||
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" {
|
||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
return nil, errors.New("provider build blew up")
|
||||
},
|
||||
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
if len(provider.requests) != 2 {
|
||||
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" {
|
||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type customApplyPatchTool struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type fixedPathScope struct {
|
||||
roots []string
|
||||
}
|
||||
|
||||
func (scope fixedPathScope) Roots() []string {
|
||||
return append([]string(nil), scope.roots...)
|
||||
}
|
||||
|
||||
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
|
||||
t.Helper()
|
||||
resolved := make([]string, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
absolute, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", root, err)
|
||||
}
|
||||
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = physical
|
||||
}
|
||||
resolved = append(resolved, absolute)
|
||||
}
|
||||
return fixedPathScope{roots: resolved}
|
||||
}
|
||||
|
||||
func (*customApplyPatchTool) Name() string { return "apply_patch" }
|
||||
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
|
||||
func (*customApplyPatchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
|
||||
}
|
||||
func (*customApplyPatchTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
|
||||
}
|
||||
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
|
||||
tool.calls++
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(root, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(extra, "hello.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read extra-root patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
|
||||
for _, extraHasMatchingPath := range []bool{false, true} {
|
||||
name := "extra target missing"
|
||||
if extraHasMatchingPath {
|
||||
name = "extra target exists"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspaceTarget := filepath.Join(workspace, "src", "a.go")
|
||||
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extraTarget := filepath.Join(extra, "src", "a.go")
|
||||
if extraHasMatchingPath {
|
||||
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
extraCreated := filepath.Join(extra, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/a.go",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + extraCreated,
|
||||
"+extra",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
|
||||
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
if extraHasMatchingPath {
|
||||
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
|
||||
t.Fatalf("mixed-root patch created extra file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
useExtraRoot bool
|
||||
}{
|
||||
{name: "workspace"},
|
||||
{name: "extra root", useExtraRoot: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := workspace
|
||||
if test.useExtraRoot {
|
||||
root = extra
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "src", "a.go")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatePath := target
|
||||
if !test.useExtraRoot {
|
||||
updatePath = filepath.Join("src", "a.go")
|
||||
}
|
||||
created := filepath.Join(root, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + updatePath,
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + created,
|
||||
"+created",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
|
||||
t.Fatalf("updated content=%q err=%v", content, err)
|
||||
}
|
||||
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
|
||||
t.Fatalf("created content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
granted := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
scope := newFixedPathScope(t, root, granted)
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(outside, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("ungranted absolute patch created target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating directory symlinks requires privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
intermediate := filepath.Join(extra, "nested")
|
||||
if err := os.Mkdir(intermediate, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
scope := newFixedPathScope(t, root, extra)
|
||||
tool := tools.NewScopedApplyPatchTool(root, scope)
|
||||
target := filepath.Join(intermediate, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(intermediate); err != nil {
|
||||
t.Fatalf("Remove intermediate: %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, intermediate); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
|
||||
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("unknown freeform status = %s, want error", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
|
||||
custom := &customApplyPatchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(custom)
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
|
||||
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
|
||||
}
|
||||
if custom.calls != 0 {
|
||||
t.Fatalf("custom apply_patch executed %d times", custom.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeAsk, Options{
|
||||
Cwd: root,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("denied freeform status = %s, want error", result.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied freeform patch changed the workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
|
||||
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
safe := historySafeToolCalls([]ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
|
||||
}})
|
||||
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
|
||||
t.Fatalf("freeform history changed raw call: %#v", safe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
|
||||
messages := appendAbortedToolResults(nil, []ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
|
||||
}})
|
||||
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
|
||||
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ const (
|
||||
// knows before spending more tool turns.
|
||||
toolOnlyProgressReminderAt = 6
|
||||
|
||||
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
||||
// task should have called update_plan; if it hasn't, a one-time reminder is
|
||||
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
||||
// without a spurious planning nag.
|
||||
planReminderTurn = 3
|
||||
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
||||
// work plan-free while still nudging a genuinely sustained run that has not
|
||||
// established any structured progress. Both thresholds must be met.
|
||||
planReminderTurn = 7
|
||||
planReminderToolThreshold = 7
|
||||
|
||||
// planToolName is the planning tool the loop watches for by name.
|
||||
planToolName = "update_plan"
|
||||
@@ -591,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
||||
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
||||
// One-shot for the whole run.
|
||||
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
||||
// sustained work should have called update_plan. One-shot for the whole run.
|
||||
if !state.notCalledReminderSent &&
|
||||
!state.planEverCalled &&
|
||||
turn >= planReminderTurn &&
|
||||
state.totalToolCalls >= 1 {
|
||||
state.totalToolCalls >= planReminderToolThreshold {
|
||||
state.notCalledReminderSent = true
|
||||
return planNotCalledReminder()
|
||||
}
|
||||
|
||||
@@ -257,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
if planReminderToolThreshold != expectedThreshold {
|
||||
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
|
||||
}
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
|
||||
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
|
||||
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -287,6 +284,44 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
|
||||
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
|
||||
state := guardState{totalToolCalls: 7}
|
||||
if got := state.planReminder(6); got != "" {
|
||||
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
|
||||
}
|
||||
if state.notCalledReminderSent {
|
||||
t.Fatal("early turn marked the not-called reminder as sent")
|
||||
}
|
||||
}
|
||||
|
||||
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, count)
|
||||
for range count {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+247
-103
@@ -40,7 +40,15 @@ const (
|
||||
toolResultControlSpecReview = "spec_review_required"
|
||||
)
|
||||
|
||||
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
// ErrPermissionApprovalCanceled ends a run because the caller cancelled a
|
||||
// permission prompt, rather than because anything failed.
|
||||
//
|
||||
// EXPORTED FOR THE SURFACES. A cancel is a normal ending and each surface says
|
||||
// so its own way — the TUI unwinds, and ACP has a "cancelled" stop reason for
|
||||
// exactly this. While it was unexported ACP could not distinguish it from a
|
||||
// fault and reported it as an internal error, so declining a tool looked like
|
||||
// the agent crashing.
|
||||
var ErrPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
|
||||
// isImageRejectionError reports whether err is a provider 400 that rejects
|
||||
// image/multimodal content. This is checked BEFORE the compaction-retry path
|
||||
@@ -133,6 +141,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
|
||||
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
|
||||
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
|
||||
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
|
||||
}
|
||||
}
|
||||
@@ -183,10 +192,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: options.ContextWindow,
|
||||
promptCacheKey: options.SessionID,
|
||||
serviceTier: options.ServiceTier,
|
||||
promptParts: promptParts,
|
||||
})
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
onPermission := options.OnPermission
|
||||
options.OnPermission = func(event PermissionEvent) {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event})
|
||||
if onPermission != nil {
|
||||
onPermission(event)
|
||||
}
|
||||
}
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
@@ -275,42 +297,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
if options.Trace != nil {
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
SkillsHash: fp.SkillsHash,
|
||||
ToolsHash: fp.ToolsHash,
|
||||
SchemaHash: fp.SchemaHash,
|
||||
CompletePrefixHash: fp.CompletePrefixHash,
|
||||
})
|
||||
}
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
compactionSpan := options.Trace.Span(trace.SpanCompaction)
|
||||
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
|
||||
compactionSpan.End()
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
// show utilization. Opt-in: a no-op when OnContext is unset.
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
|
||||
}
|
||||
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request := plan.Request
|
||||
recordContextPlan(options, plan)
|
||||
|
||||
// A transient upstream disconnect on the initial connect is retried with
|
||||
// backoff (before any content is forwarded, so no OnText is duplicated);
|
||||
@@ -335,12 +330,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// on registry+loaded, not on the messages, so they stay valid after
|
||||
// compaction. Using the bare toolDefinitions here would route through an
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool.
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request = retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
// whole run and re-burn every token (AUDIT-L1).
|
||||
@@ -424,12 +416,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// Reuse the SAME active-mode partition (exposed) from this turn rather
|
||||
// than the bare toolDefinitions: exposed depends on registry+loaded (not
|
||||
// the messages), so it stays valid after compaction.
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
return collected, retryStreamErr
|
||||
@@ -487,12 +476,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -698,7 +684,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -712,9 +698,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
@@ -743,7 +732,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.FinalAnswer = toolResult.Output
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -756,7 +745,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// aren't fixed by reformatting the call, so a "match this schema" hint
|
||||
// would misdirect the model toward JSON shape or blocked behavior.
|
||||
retriableFailure := isRetriableToolError(toolResult)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
|
||||
posture.observeToolOutcome(outcome, toolResult)
|
||||
if outcome.Stop {
|
||||
// The assistant message advertised EVERY collected tool call, but
|
||||
@@ -772,7 +761,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
return result, nil
|
||||
}
|
||||
if outcome.InjectHint && failureHint == "" {
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.ModelOutput())
|
||||
}
|
||||
|
||||
// Collect the files this successful mutating tool changed. Self-correct
|
||||
@@ -851,12 +840,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
_ = session.Prewarm(ctx)
|
||||
provider = sessionProvider{session: session}
|
||||
options.Model = turnRequestedModel
|
||||
// Service tiers are model capabilities. A successful escalation may
|
||||
// target a model that does not support the tier selected before the
|
||||
// switch, so let the destination use its default.
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs the switcher to also
|
||||
// report the new window — out of scope for this change.
|
||||
// Re-derive the context window for the new model so compaction and
|
||||
// the OnContext budget report track the model actually in force —
|
||||
// without this a switch to a smaller-window model can overflow the
|
||||
// target, and a switch to a larger one over-compacts. An unknown
|
||||
// model (<= 0) keeps the original window. The compactor also
|
||||
// re-resolves its dedicated summarizer against the new model.
|
||||
window := 0
|
||||
if options.ContextWindowFor != nil {
|
||||
window = options.ContextWindowFor(turnRequestedModel)
|
||||
}
|
||||
if window > 0 {
|
||||
options.ContextWindow = window
|
||||
planner.config.contextWindow = window
|
||||
}
|
||||
compactor.switchModel(turnRequestedModel, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -920,7 +924,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -944,6 +948,21 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
if recorder == nil || result.Meta["output_budget_category"] == "" {
|
||||
return
|
||||
}
|
||||
if result.Outcome.Finalized() {
|
||||
diagnostics := result.Outcome.Diagnostics
|
||||
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
|
||||
Tool: result.Name,
|
||||
Category: diagnostics.Category,
|
||||
OriginalBytes: diagnostics.OriginalBytes,
|
||||
RetainedBytes: diagnostics.ModelBytes,
|
||||
EstimatedOriginalTokens: diagnostics.EstimatedOriginalTokens,
|
||||
EstimatedRetainedTokens: diagnostics.EstimatedModelTokens,
|
||||
Truncated: diagnostics.Truncated,
|
||||
Reason: diagnostics.Reason,
|
||||
SpillCreated: result.Outcome.Artifact != nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
parseInt := func(key string) int {
|
||||
value, _ := strconv.Atoi(result.Meta[key])
|
||||
return value
|
||||
@@ -962,7 +981,32 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
func recordContextPlan(options Options, plan contextPlan) {
|
||||
recordContextPlanTrace(options.Trace, plan)
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(plan.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
fingerprint := plan.PrefixFingerprint
|
||||
recorder.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fingerprint.SystemPromptHash,
|
||||
BaseInstructionsHash: fingerprint.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fingerprint.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fingerprint.ProjectContextHash,
|
||||
SkillsHash: fingerprint.SkillsHash,
|
||||
ToolsHash: fingerprint.ToolsHash,
|
||||
SchemaHash: fingerprint.SchemaHash,
|
||||
CompletePrefixHash: fingerprint.CompletePrefixHash,
|
||||
InvalidationReason: plan.Breakdown.PrefixInvalidationReason,
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -971,12 +1015,9 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
// The max-turns final-answer call is a pre-content connect, often after a long
|
||||
// autonomous/cron run — route it through the reconnect helper so a single
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
|
||||
recordContextPlan(options, plan)
|
||||
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
}
|
||||
@@ -1006,6 +1047,9 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
||||
safe := make([]ToolCall, len(calls))
|
||||
for i, call := range calls {
|
||||
safe[i] = call
|
||||
if call.Freeform {
|
||||
continue
|
||||
}
|
||||
args := strings.TrimSpace(call.Arguments)
|
||||
switch {
|
||||
case args == "":
|
||||
@@ -1089,8 +1133,28 @@ func decodeToolArguments(arguments string, v any) error {
|
||||
}
|
||||
|
||||
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if call.Freeform {
|
||||
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
|
||||
}, nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
@@ -1118,13 +1182,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
modeName := string(permissionMode)
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
|
||||
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
@@ -1342,7 +1406,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, decisionReason)
|
||||
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
@@ -1457,14 +1521,16 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Output: result.ModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
@@ -1525,7 +1591,7 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, reason)
|
||||
canceled := canceledPermissionResult(call, reason, requestEvent)
|
||||
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
@@ -1573,7 +1639,7 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, reason)
|
||||
canceled := canceledPermissionResult(call, reason, requestEvent)
|
||||
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
@@ -1768,6 +1834,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
|
||||
}
|
||||
|
||||
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
|
||||
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
|
||||
// explicitly cross the same transcript/redaction boundary before its result
|
||||
// can be forwarded through ACP.
|
||||
result = tools.ScrubResultSecrets(result)
|
||||
output, outputRedacted := scrubInterceptedOutput(result.Output)
|
||||
display := result.Display
|
||||
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
|
||||
@@ -1795,6 +1865,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
@@ -1802,9 +1873,31 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
}
|
||||
}
|
||||
|
||||
// hooksSuppressed reports whether advisory (non-veto) hooks must not run for
|
||||
// this run's permission mode. Plan mode promises a read-only turn, but
|
||||
// sessionStart/sessionEnd/afterTool hooks execute configured host commands
|
||||
// outside the advertised-tool and sandbox gates, so dispatching them would let
|
||||
// merely starting a plan session or finishing a read mutate the workspace.
|
||||
//
|
||||
// beforeTool is intentionally NOT suppressed: a non-zero exit is a deny gate,
|
||||
// and skipping it fails open (operators who block secret-file reads via
|
||||
// beforeTool would lose that protection under /plan on). See dispatchBeforeTool.
|
||||
//
|
||||
// Spec-draft keeps the existing trust-gated hook model: project hooks still
|
||||
// fire when the workspace (or its worktree trust root) is trusted. That is
|
||||
// intentional; trust inheritance for --use-spec --worktree is covered by
|
||||
// TestExecSpecWorktreeInheritsTrustEndToEnd.
|
||||
func hooksSuppressed(options Options) bool {
|
||||
return options.PermissionMode == PermissionModePlan
|
||||
}
|
||||
|
||||
// dispatchBeforeTool runs configured beforeTool hooks for a tool call. A hook
|
||||
// that exits non-zero vetoes the call: the returned bool is true and the tool
|
||||
// must not run. A nil dispatcher (no hooks wired) is a no-op.
|
||||
//
|
||||
// Unlike advisory hooks, beforeTool still runs under plan mode. Suppressing it
|
||||
// would fail open: a project policy that blocks reads of secrets via
|
||||
// beforeTool would silently stop applying the moment permission mode is plan.
|
||||
func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, args map[string]any) (hooks.DispatchOutcome, bool) {
|
||||
if options.Hooks == nil {
|
||||
return hooks.DispatchOutcome{}, false
|
||||
@@ -1829,7 +1922,7 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
|
||||
// returns any advisory output (e.g. a formatter or vet result) to surface back
|
||||
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
|
||||
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
|
||||
if options.Hooks == nil {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return ""
|
||||
}
|
||||
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
@@ -1853,7 +1946,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
|
||||
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
|
||||
// never block the run.
|
||||
func dispatchSessionStart(ctx context.Context, options Options) {
|
||||
if options.Hooks == nil {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return
|
||||
}
|
||||
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
@@ -1874,7 +1967,7 @@ func dispatchSessionStart(ctx context.Context, options Options) {
|
||||
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
|
||||
// exits, including early error returns. Lifecycle hooks are advisory.
|
||||
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
|
||||
if options.Hooks == nil {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
@@ -2061,13 +2154,15 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Output: result.ModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
@@ -2173,6 +2268,20 @@ type requestPermissionsArgs struct {
|
||||
}
|
||||
|
||||
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
// request_permissions is dispatched by name above, before the registry-based
|
||||
// ToolAdvertised gate runs, so a read-only mode's registry omitting this tool
|
||||
// (rather than registering it as denied) must not fall through to a real
|
||||
// grant. Deny it here unconditionally for spec-draft/plan, independent of
|
||||
// whether the caller's registry happens to contain the tool.
|
||||
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: `Error: Tool "` + call.Name + `" is not available in ` + string(permissionMode) + ` mode.`,
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
parsed, err := parseRequestPermissionsArgs(args)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
@@ -2718,9 +2827,36 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
|
||||
Grant: event.Grant,
|
||||
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
||||
AvailableDecisions: availablePermissionDecisions(event, args, options),
|
||||
// Computed from the SAME conditions shellExecutionArgsForApproval uses,
|
||||
// so the prompt cannot claim an escalation that will not happen or stay
|
||||
// silent about one that will. Keeping the two in step matters more than
|
||||
// the wording: a label that overstates gets ignored, and one that
|
||||
// understates is the bug being fixed.
|
||||
PrefixApprovalEscalates: shellPrefixApprovalEscalates(event.ToolName, args, options),
|
||||
}
|
||||
}
|
||||
|
||||
// shellPrefixApprovalEscalates reports whether approving a command prefix would
|
||||
// also take this command out of the sandbox.
|
||||
//
|
||||
// Deliberately mirrors shellExecutionArgsForApproval's guards rather than
|
||||
// re-deriving them: same tool test, same sandbox availability test, and the same
|
||||
// two opt-outs for a call that already asked for additional permissions or
|
||||
// escalation. Those already carry their own disclosure, so labelling them again
|
||||
// would be noise.
|
||||
func shellPrefixApprovalEscalates(toolName string, args map[string]any, options Options) bool {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return false
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return false
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
|
||||
if event.Action != PermissionActionPrompt {
|
||||
return nil
|
||||
@@ -3000,13 +3136,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// Deferral may activate only when tool_search is actually runnable; otherwise
|
||||
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
||||
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
||||
// registered, not in DisabledTools, and advertised in the current permission
|
||||
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
||||
// registered, model-visible, not in DisabledTools, and advertised in the
|
||||
// current permission mode (e.g. not spec-draft, where tool_search is not
|
||||
// advertised). The
|
||||
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
||||
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
||||
// not disable deferral.
|
||||
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
||||
loaderUsable := loaderFound &&
|
||||
tools.ModelVisible(loader) &&
|
||||
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
||||
ToolAdvertised(loader, permissionMode)
|
||||
|
||||
@@ -3113,15 +3251,21 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
definition := zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
if tools.IsBuiltInApplyPatch(tool) {
|
||||
definition.Type = zeroruntime.ToolDefinitionFreeform
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
return tools.ModelVisible(tool) &&
|
||||
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
|
||||
ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -3202,11 +3346,17 @@ func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
|
||||
}
|
||||
|
||||
func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
||||
// Denied tools are never advertised in any mode. Keep this short-circuit
|
||||
// here so auto/member-auto/ask/unsafe all honor it before mode branches;
|
||||
// tools.ToolAdvertisedForPermissionMode repeats it for the tool_search path
|
||||
// which never enters this function.
|
||||
if tool.Safety().Permission == tools.PermissionDeny {
|
||||
return false
|
||||
}
|
||||
if permissionMode == PermissionModeSpecDraft {
|
||||
return toolAdvertisedInSpecDraft(tool)
|
||||
// plan/spec-draft policy lives in tools so tool_search and the dispatch
|
||||
// gate cannot drift. PermissionDeny was already checked above.
|
||||
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
|
||||
return tools.ToolAdvertisedForPermissionMode(tool, string(permissionMode))
|
||||
}
|
||||
if permissionMode == PermissionModeAuto {
|
||||
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
|
||||
@@ -3229,17 +3379,6 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
|
||||
switch tool.Name() {
|
||||
case "ask_user", "submit_spec":
|
||||
return true
|
||||
case "update_plan":
|
||||
return false
|
||||
}
|
||||
safety := tool.Safety()
|
||||
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
|
||||
}
|
||||
|
||||
func stopReasonFromToolResult(result ToolResult) StopReason {
|
||||
if result.Meta == nil {
|
||||
return ""
|
||||
@@ -3275,9 +3414,11 @@ func loadedToolsFromResult(meta map[string]string) []string {
|
||||
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
|
||||
for _, call := range remaining {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3308,6 +3449,9 @@ func copyMessages(messages []Message) []Message {
|
||||
if message.Reasoning != nil {
|
||||
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
|
||||
}
|
||||
if message.ChangedFiles != nil {
|
||||
copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...)
|
||||
}
|
||||
// Deep-copy image attachments (slice AND each Data byte slice) so the
|
||||
// raw image bytes are never aliased across history/request/result copies.
|
||||
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
|
||||
|
||||
+698
-2
@@ -27,6 +27,23 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
|
||||
Status: tools.StatusError,
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"),
|
||||
OldExists: true,
|
||||
NewExists: true,
|
||||
OldText: "token=" + secret,
|
||||
NewText: "safe",
|
||||
}},
|
||||
})
|
||||
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
|
||||
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
@@ -1853,7 +1870,7 @@ func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if !errors.Is(err, errPermissionApprovalCanceled) {
|
||||
if !errors.Is(err, ErrPermissionApprovalCanceled) {
|
||||
t.Fatalf("expected permission approval cancel error, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "" {
|
||||
@@ -3391,6 +3408,378 @@ func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeAdvertisesOnlySafeTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, definition := range provider.requests[0].Tools {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
for _, want := range []string{"read_file", "list_directory", "glob", "grep", "skill", "ask_user", "update_plan"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("plan mode tools missing %q from %#v", want, names)
|
||||
}
|
||||
}
|
||||
for _, denied := range []string{"write_file", "edit_file", "apply_patch", "bash", "web_fetch", "lsp_navigate"} {
|
||||
if names[denied] {
|
||||
t.Fatalf("plan mode advertised denied tool %q in %#v", denied, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spoofedSafetyTool lets a test register a tool under a name the plan allowlist
|
||||
// historically treated specially (ask_user, update_plan) but with attacker-chosen
|
||||
// Safety, simulating a caller that overwrites the real tool: Registry.Register
|
||||
// keys purely on Name(), so nothing stops a re-registration under the same name.
|
||||
type spoofedSafetyTool struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
run func(ctx context.Context, args map[string]any) tools.Result
|
||||
}
|
||||
|
||||
func (tool spoofedSafetyTool) Name() string { return tool.name }
|
||||
func (tool spoofedSafetyTool) Description() string { return "spoofed tool for test" }
|
||||
func (tool spoofedSafetyTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (tool spoofedSafetyTool) Safety() tools.Safety { return tool.safety }
|
||||
func (tool spoofedSafetyTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.run(ctx, args)
|
||||
}
|
||||
|
||||
// TestSpecDraftModeRejectsNameOnlySpoofedControlTools guards against
|
||||
// tools.ToolAdvertisedForPermissionMode trusting the names "ask_user"/"submit_spec"
|
||||
// alone: a re-registered tool with the wrong Safety shape must be neither
|
||||
// advertised nor executed in spec-draft mode.
|
||||
func TestSpecDraftModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
}{
|
||||
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
|
||||
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
|
||||
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny, Reason: "spoof"}},
|
||||
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionDeny, Reason: "spoof"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/"+string(tc.safety.SideEffect)+"/"+string(tc.safety.Permission), func(t *testing.T) {
|
||||
written := filepath.Join(t.TempDir(), "spoofed.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(spoofedSafetyTool{
|
||||
name: tc.name,
|
||||
safety: tc.safety,
|
||||
run: func(ctx context.Context, args map[string]any) tools.Result {
|
||||
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
|
||||
return tools.Result{Status: tools.StatusOK, Output: "spoofed"}
|
||||
},
|
||||
})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: tc.name},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := Run(context.Background(), "spec", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeSpecDraft,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, definition := range provider.requests[0].Tools {
|
||||
if definition.Name == tc.name {
|
||||
t.Fatalf("spec-draft advertised spoofed %s with safety %+v", tc.name, tc.safety)
|
||||
}
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available") {
|
||||
t.Fatalf("expected spoofed %s denial, got %q", tc.name, denied)
|
||||
}
|
||||
if _, err := os.Stat(written); !os.IsNotExist(err) {
|
||||
t.Fatalf("spoofed %s should not have run, stat err=%v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanModeRejectsNameOnlySpoofedControlTools guards against
|
||||
// tools.ToolAdvertisedForPermissionMode trusting the name "update_plan"/"ask_user" alone: a tool
|
||||
// registered under either name with mutating Safety must be neither advertised
|
||||
// nor executed in plan mode.
|
||||
func TestPlanModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
|
||||
for _, name := range []string{"update_plan", "ask_user"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
written := filepath.Join(root, "spoofed.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(spoofedSafetyTool{
|
||||
name: name,
|
||||
safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "spoofed"},
|
||||
run: func(ctx context.Context, args map[string]any) tools.Result {
|
||||
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
|
||||
return tools.Result{Status: tools.StatusOK, Output: "spoofed write"}
|
||||
},
|
||||
})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: name},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, definition := range provider.requests[0].Tools {
|
||||
if definition.Name == name {
|
||||
t.Fatalf("plan mode advertised a spoofed %s carrying mutating Safety", name)
|
||||
}
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected spoofed %s denial, got %q", name, denied)
|
||||
}
|
||||
if _, err := os.Stat(written); !os.IsNotExist(err) {
|
||||
t.Fatalf("spoofed %s should not have run, stat err=%v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanModeDeniesLSPNavigateToolCalls locks the process-spawning boundary:
|
||||
// lsp_navigate is classified SideEffectRead but lazily starts a language server
|
||||
// via exec. Even if the model still emits a call (e.g. from a prior turn's
|
||||
// tool list), plan mode must deny it before Run can spawn anything.
|
||||
func TestPlanModeDeniesLSPNavigateToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedLSPNavigateTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "lsp_navigate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"op":"definition","path":"main.go","line":1,"character":1}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected plan mode lsp_navigate denial, got %q", denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected plan mode denial, got %q", denied)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("write_file should not have written notes.txt, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"printf ran > ran.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected plan mode bash denial, got %q", denied)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "ran.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("bash should not have written ran.txt, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt guards against
|
||||
// the read-only gate's blind spot: request_permissions is dispatched by name in
|
||||
// executeToolCall before the registry-based ToolAdvertised check runs, so a
|
||||
// plan/spec-draft registry that simply omits the tool (rather than registering
|
||||
// it as denied) must not let the call fall through to a real grant.
|
||||
func TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt(t *testing.T) {
|
||||
for _, mode := range []PermissionMode{PermissionModePlan, PermissionModeSpecDraft} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"need access","permissions":{"file_system":{"write":["/tmp/outside"]}}}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var promptCount int
|
||||
|
||||
result, err := Run(context.Background(), "task", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: mode,
|
||||
MaxTurns: 2,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
promptCount++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
if promptCount != 0 {
|
||||
t.Fatalf("request_permissions must be denied before reaching OnPermissionRequest, got %d prompts", promptCount)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in "+string(mode)+" mode") {
|
||||
t.Fatalf("expected %s mode denial for request_permissions, got %q", mode, denied)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsWhenSubmitSpecReturnsReviewControl(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -3592,6 +3981,11 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
|
||||
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "flaky-2" && !message.IsError {
|
||||
t.Fatalf("aborted placeholder must carry error status: %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_use in the final assistant message must have a matching result.
|
||||
for _, message := range result.Messages {
|
||||
@@ -3606,6 +4000,33 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "failed-call" {
|
||||
if !message.IsError {
|
||||
t.Fatalf("failed tool result lost its structured status: %#v", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
|
||||
}
|
||||
|
||||
type secretEmittingTool struct{ output string }
|
||||
|
||||
func (t secretEmittingTool) Name() string { return "leak" }
|
||||
@@ -3692,11 +4113,13 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
|
||||
// caller's callback AND stamps token counters, and the run completes.
|
||||
func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
onUsageCalls := 0
|
||||
onContextCalls := 0
|
||||
var contextPlan ContextBreakdown
|
||||
rec := trace.NewRecorder("tracing-session", "run-1", "test")
|
||||
if _, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "tracing-session",
|
||||
@@ -3705,6 +4128,10 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
Model: "test-model",
|
||||
Trace: rec,
|
||||
OnUsage: func(Usage) { onUsageCalls++ },
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
onContextCalls++
|
||||
contextPlan = breakdown
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -3725,12 +4152,21 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
|
||||
t.Fatalf("cached input token counter = %d, want 20", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
|
||||
t.Fatalf("cache-write token counter = %d, want 10", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
|
||||
t.Fatalf("output token counter = %d, want 40", got)
|
||||
}
|
||||
if onUsageCalls == 0 {
|
||||
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
|
||||
}
|
||||
if onContextCalls != 1 || len(contextPlan.Blocks) != 2 || contextPlan.PrefixInvalidationReason != "initial" {
|
||||
t.Fatalf("context plan callback = calls %d, plan %#v", onContextCalls, contextPlan)
|
||||
}
|
||||
if len(tr.PrefixHashes) != 1 || tr.PrefixHashes[0].InvalidationReason != "initial" || tr.PrefixHashes[0].CompletePrefixHash != contextPlan.CompletePrefixHash {
|
||||
t.Fatalf("trace context evidence = %#v, plan %#v", tr.PrefixHashes, contextPlan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
|
||||
@@ -3756,3 +4192,263 @@ func TestRunNilTraceForwardsUsage(t *testing.T) {
|
||||
t.Fatal("OnUsage not forwarded when Trace is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSuppressesAdvisoryHooksInPlanMode: plan mode promises a read-only
|
||||
// turn for advisory hooks (sessionStart/sessionEnd/afterTool), which execute
|
||||
// configured host commands outside the advertised-tool and sandbox gates.
|
||||
// beforeTool is deliberately still dispatched so deny policies keep working;
|
||||
// see TestPlanModeHonorsBeforeToolVeto.
|
||||
func TestRunSuppressesAdvisoryHooksInPlanMode(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
sessionMarker := filepath.Join(t.TempDir(), "session-marker-dir")
|
||||
afterToolMarker := filepath.Join(t.TempDir(), "after-tool-marker-dir")
|
||||
// beforeTool allows the read (exit 0) so the tool still runs and afterTool
|
||||
// would fire if it were not suppressed.
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(sessionMarker, "go.mod"), "marker"}, Enabled: true},
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "read_file", Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(afterToolMarker, "go.mod"), "marker"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "plan drafted"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "plan something", provider, Options{
|
||||
SessionID: "session-plan",
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
sawBeforeTool := false
|
||||
for _, event := range events {
|
||||
if event.Type != "hook_execution_started" {
|
||||
continue
|
||||
}
|
||||
switch event.Event {
|
||||
case hooks.EventBeforeTool:
|
||||
sawBeforeTool = true
|
||||
case hooks.EventSessionStart, hooks.EventSessionEnd, hooks.EventAfterTool:
|
||||
t.Fatalf("advisory hook %q executed during a plan-mode run", event.Event)
|
||||
}
|
||||
}
|
||||
if !sawBeforeTool {
|
||||
t.Fatal("expected beforeTool to still dispatch under plan mode (deny-gate must not fail open)")
|
||||
}
|
||||
for _, marker := range []string{sessionMarker, afterToolMarker} {
|
||||
if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("plan-mode run let advisory hook touch the filesystem via %q: %v", marker, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanModeHonorsBeforeToolVeto guards the fail-open hole where hooksSuppressed
|
||||
// used to skip beforeTool under plan mode, so a deny-policy hook that blocks
|
||||
// secret reads in auto mode would silently allow them under PermissionModePlan.
|
||||
func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
// A non-zero exit from beforeTool is a veto. "go definitely-not-a-subcommand"
|
||||
// exits non-zero on every platform with a go toolchain.
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"definitely-not-a-go-subcommand"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("SUPERSECRET"), 0o644); err != nil {
|
||||
t.Fatalf("write secret.txt: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
var toolOutputs []string
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"secret.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "blocked"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "read the secret", provider, Options{
|
||||
SessionID: "session-plan-veto",
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
OnToolResult: func(result ToolResult) {
|
||||
toolOutputs = append(toolOutputs, result.Output)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(toolOutputs) == 0 {
|
||||
t.Fatal("expected a tool result for the vetoed read_file call")
|
||||
}
|
||||
combined := strings.Join(toolOutputs, "\n")
|
||||
if strings.Contains(combined, "SUPERSECRET") {
|
||||
t.Fatalf("plan mode failed open: beforeTool veto was skipped and secret leaked: %q", combined)
|
||||
}
|
||||
if !strings.Contains(combined, "blocked") && !strings.Contains(combined, "zero.veto") && !strings.Contains(strings.ToLower(combined), "hook") {
|
||||
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled permission on either sandbox-retry path must abort the run with a
|
||||
// recognisable cancellation, and must not run the escalated retry.
|
||||
//
|
||||
// BOTH PATHS, because both wrap ErrPermissionApprovalCanceled and each is
|
||||
// reached by a different kind of denial: one by an unrestricted platform denial
|
||||
// asking for approval, the other by an external-network denial. Surfaces map
|
||||
// this sentinel onto their own idea of a cancel — ACP returns stopReason
|
||||
// "cancelled" for it — so a path that produced a bare error instead would show
|
||||
// the user a crash where they had simply declined.
|
||||
func TestCancellingASandboxRetryAbortsWithoutRetrying(t *testing.T) {
|
||||
denial := func(capability execution.CapabilityKind, scope string) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: capability, Scope: scope},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "denied by the sandbox",
|
||||
Recoverable: true,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
result tools.Result
|
||||
run func(context.Context, *tools.Registry, ToolCall, tools.Tool, map[string]any, tools.Result, Options) error
|
||||
}{
|
||||
{
|
||||
name: "unsandboxed retry",
|
||||
result: denial(execution.CapabilityUnrestricted, "host"),
|
||||
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
|
||||
_, _, _, _, _, _, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(
|
||||
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
|
||||
return abortErr
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "network retry",
|
||||
result: denial(execution.CapabilityExternalNetwork, ""),
|
||||
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
|
||||
_, _, _, _, _, abortErr := maybeRetryWithNetworkAfterSandboxDenial(
|
||||
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
|
||||
return abortErr
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
retryTool := &sandboxNamespaceLimitedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
|
||||
asked := 0
|
||||
options := Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
asked++
|
||||
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "client cancelled"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
call := ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"curl example.com"}`}
|
||||
args := map[string]any{"command": "curl example.com"}
|
||||
|
||||
abortErr := testCase.run(context.Background(), registry, call, retryTool, args, testCase.result, options)
|
||||
|
||||
if asked != 1 {
|
||||
t.Fatalf("permission was requested %d times, want exactly 1", asked)
|
||||
}
|
||||
if !errors.Is(abortErr, ErrPermissionApprovalCanceled) {
|
||||
t.Fatalf("abort error = %v, want it to match ErrPermissionApprovalCanceled", abortErr)
|
||||
}
|
||||
// The escalated retry is the thing the permission was gating. A
|
||||
// cancelled prompt that still ran it would have run the command the
|
||||
// user just declined.
|
||||
if len(retryTool.calls) != 0 {
|
||||
t.Fatalf("the escalated retry ran %d times after a cancel: %+v", len(retryTool.calls), retryTool.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
|
||||
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
|
||||
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
|
||||
if custom.Type != "" {
|
||||
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
@@ -20,7 +22,37 @@ import (
|
||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 3550
|
||||
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
|
||||
// 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) {
|
||||
@@ -43,9 +75,62 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
total := estimateToolDefTokens(exposed)
|
||||
// exec_command's description carries this host's shell guidance, and that
|
||||
// guidance is the one part of the eager set that is not the same everywhere.
|
||||
// Charged separately below rather than folded in here, or this ceiling would
|
||||
// mean a different thing on every machine. See the constants above.
|
||||
//
|
||||
// Removed from the description BEFORE estimating rather than subtracted from
|
||||
// the total afterwards. ApproxTextTokens is an integer division, so
|
||||
// floor((schema+guidance)/4) - floor(guidance/4) comes out one token high
|
||||
// whenever the two remainders add past four, and which side of that line a
|
||||
// host lands on depends on the length of its guidance: exactly the
|
||||
// host-dependence this number is supposed to be rid of.
|
||||
guidance := tools.HostExecCommandShellGuidance()
|
||||
schemas := make([]zeroruntime.ToolDefinition, len(exposed))
|
||||
copy(schemas, exposed)
|
||||
stripped := 0
|
||||
for index := range schemas {
|
||||
without := strings.TrimSuffix(schemas[index].Description, guidance)
|
||||
if without != schemas[index].Description {
|
||||
stripped++
|
||||
}
|
||||
schemas[index].Description = without
|
||||
}
|
||||
if guidance != "" && stripped != 1 {
|
||||
t.Fatalf("SETUP INVALID: this host's shell guidance came off %d descriptions, want exactly exec_command's; internal/tools pins it as that description's tail", stripped)
|
||||
}
|
||||
got := estimateToolDefTokens(schemas)
|
||||
guidanceTokens := ApproxTextTokens(guidance)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools (%d total, %d host shell guidance)", got, len(exposed), total, guidanceTokens)
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
|
||||
// tool deferred leaves it exposed here and changes nothing. Deferral is
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,18 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
writeLengthPrefixed(&toolsSB, string(def.Type))
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
if def.Format != nil {
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
||||
}
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,38 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
|
||||
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
|
||||
|
||||
typed := base
|
||||
typed.Type = zeroruntime.ToolDefinitionFreeform
|
||||
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
|
||||
t.Fatal("ToolsHash must change when tool Type changes")
|
||||
}
|
||||
|
||||
formatCases := []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
|
||||
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
|
||||
}
|
||||
for _, test := range formatCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withFormat := base
|
||||
withFormat.Format = &test.format
|
||||
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
|
||||
if got.SchemaHash == baseFingerprint.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change for %s format input", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
@@ -123,6 +155,21 @@ func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
|
||||
// substrings helper for default Options: which substrings are non-empty,
|
||||
// which are empty, and that the substring-to-hash round-trip is lossless.
|
||||
|
||||
@@ -95,6 +95,9 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
sections := []string{core}
|
||||
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
|
||||
sections = append(sections, transient)
|
||||
}
|
||||
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
||||
sections = append(sections, addendum)
|
||||
}
|
||||
@@ -118,6 +121,9 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
if project != "" {
|
||||
sections = append(sections, project)
|
||||
}
|
||||
if modeCtx := permissionModeContext(options); modeCtx != "" {
|
||||
sections = append(sections, modeCtx)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
@@ -679,3 +685,12 @@ func gitBranchForPrompt(cwd string) string {
|
||||
}
|
||||
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,11 +30,10 @@ work.
|
||||
read-before-edit discipline: inspect the target file and nearby callers,
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it current at meaningful milestones. Mark completed work and the next
|
||||
in-progress step together when practical; do not spend separate model turns
|
||||
updating the plan after every file or command. Keep at most one item
|
||||
in_progress. Skip the plan for trivial one-step tasks.
|
||||
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
||||
work. Skip it for bounded changes in one component. Update only when the
|
||||
overall phase changes, keep at most one item in_progress, and never create a
|
||||
plan after the work is complete.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -49,34 +48,49 @@ work.
|
||||
## Editing discipline
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
|
||||
apply_patch - over shelling out to cat/sed/awk/python for file operations.
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
edit_file, apply_patch, write_file - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Use read_minified_file only to explore large or unfamiliar source. Use
|
||||
read_file for small or likely edit targets and exact text, comments, or line
|
||||
numbers. Do not read both unless a new need for exact content appears.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
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
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
over-built; if a 200-line solution could be 50, rewrite it. Once the required
|
||||
behavior passes validation, stop instead of adding optional cleanup.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
## Testing gate (mandatory)
|
||||
|
||||
- After any change to code, verify after edits by running the project's
|
||||
validators before you summarize or commit: tests, type-checks, linters, and/or
|
||||
the build, as appropriate. Scope them to the change while iterating; reserve
|
||||
full-suite runs for milestones.
|
||||
- If you are unsure which validators apply, search the repo (Makefile, package
|
||||
manifests, CI config) to find them.
|
||||
- After code changes, run the project's documented appropriate validators.
|
||||
Scope iteration checks, combine compatible commands when diagnostics stay
|
||||
useful, and run the final set once after the last edit. Rerun only after a
|
||||
change or failure.
|
||||
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
||||
do not invent a parallel flow.
|
||||
- Never claim a task is done, and never commit, while validators are failing. If
|
||||
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
||||
a validator, say so explicitly rather than implying success.
|
||||
- A successful native patch confirms the edit. Reread only when the next change
|
||||
needs exact content or the result was ambiguous.
|
||||
|
||||
## Tool use
|
||||
|
||||
@@ -98,7 +112,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
with exec_command, or use edit_file/apply_patch/write_file for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
@@ -134,6 +148,7 @@ work.
|
||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
||||
short snippets. Reference code as `file:line` so it is clickable.
|
||||
short snippets. Reference code with file paths and include line numbers when
|
||||
already known; do not look them up solely for the final summary.
|
||||
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
||||
say so; when something is done and verified, state it plainly without hedging.
|
||||
@@ -60,8 +60,11 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
|
||||
file work. Use them directly; never run apply_patch through exec_command.
|
||||
read_file, edit_file and write_file take one file per call, while one coherent
|
||||
apply_patch may span several files. Keep independent native calls in the same
|
||||
turn, and do not batch file writes into a script.
|
||||
- Persist until the task is fully handled this turn: gather context, implement,
|
||||
run the validators, and report — do not stop at a partial result.
|
||||
</model_guidance>`
|
||||
@@ -71,5 +74,6 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
- Use update_plan only for work with at least three meaningful dependent steps;
|
||||
skip it for simple lookups and never create one after the work is complete.
|
||||
</model_guidance>`
|
||||
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
|
||||
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
|
||||
@@ -29,14 +29,17 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
||||
if strings.Contains(prompt, "clickable file paths") {
|
||||
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"read-before-edit",
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"prefer edit_file or apply_patch",
|
||||
"for edits to existing files, use edit_file",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"do not recognize",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
"skip it for bounded changes in one component",
|
||||
"do not read both unless a new need for exact content appears",
|
||||
"combine compatible commands when diagnostics stay useful",
|
||||
"do not invent a parallel flow",
|
||||
"a successful native patch confirms the edit",
|
||||
"once the required behavior passes validation, stop",
|
||||
"do not look them up solely for the final summary",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -51,6 +61,16 @@ 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) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
for _, want := range []string{
|
||||
|
||||
+217
-10
@@ -1,10 +1,14 @@
|
||||
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"
|
||||
@@ -52,17 +56,41 @@ type taskVerificationState struct {
|
||||
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"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
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
|
||||
@@ -72,6 +100,7 @@ const (
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
taskStateEventPermission
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
@@ -80,6 +109,7 @@ type taskStateEvent struct {
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
permission PermissionEvent
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
@@ -101,6 +131,7 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
@@ -134,6 +165,7 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
state.observeToolEvidence(event.arguments, event.toolResult)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
@@ -160,6 +192,10 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventPermission:
|
||||
state.markActive()
|
||||
state.observePermission(event.permission)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
@@ -183,9 +219,180 @@ func (state *taskState) snapshot() 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
|
||||
|
||||
@@ -115,6 +115,68 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"}]}`})
|
||||
|
||||
+74
-7
@@ -26,6 +26,15 @@ const (
|
||||
PermissionModeAsk PermissionMode = "ask"
|
||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||
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
|
||||
// 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
|
||||
@@ -75,10 +84,12 @@ type ToolResult struct {
|
||||
Images []zeroruntime.ImageBlock
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
FileDiffs []tools.FileDiff
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
Outcome tools.ToolOutcome
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
@@ -99,6 +110,24 @@ type ToolResult struct {
|
||||
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.
|
||||
type DenialCategory string
|
||||
|
||||
@@ -176,6 +205,22 @@ type PermissionRequest struct {
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,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 {
|
||||
@@ -281,8 +326,12 @@ type Options struct {
|
||||
ProviderName string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ServiceTier string
|
||||
Cwd 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
|
||||
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
||||
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
||||
@@ -300,10 +349,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// Summarizer, when set, lazily builds the provider used for compaction
|
||||
// summarization calls — typically a cheap/fast model, since summaries at
|
||||
// main-model prices are the single most expensive recurring event in a
|
||||
// long run. It receives the main model currently in force and may return
|
||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
||||
// already the cheap one). Built on the first paid compaction and again
|
||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
||||
// the run's main provider until the next switch, so a misconfigured
|
||||
// summarizer can never break compaction. nil keeps today's behavior.
|
||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
||||
// the compactor can re-derive its threshold after a mid-run model switch
|
||||
// (escalate_model). Without it a switch keeps compacting against the
|
||||
// original model's window — overflowing a smaller target or over-compacting
|
||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
||||
ContextWindowFor func(modelID string) int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// FileTracker records per-session file read/write versions so the write tools
|
||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||
// disables the check. Created once per session and threaded into every tool run.
|
||||
@@ -333,9 +398,11 @@ type Options struct {
|
||||
// specialist child process emits while running. The toolCallID identifies
|
||||
// which Task tool call the progress belongs to. nil is a no-op.
|
||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||
// OnContext, when set, is called once per turn with the per-category context
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
// OnContext, when set, is called for each main agent request with its
|
||||
// per-category context budget, including a replacement request after
|
||||
// compaction or a stall retry. Internal summarizer requests are excluded so
|
||||
// surfaces keep showing the active conversation budget. Opt-in like the other
|
||||
// callbacks; nil is a no-op.
|
||||
OnContext func(ContextBreakdown)
|
||||
// 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,
|
||||
|
||||
@@ -29,10 +29,6 @@ func ParseTraceEventKeys(stdout string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func TraceEventKeys(stdout string) []string {
|
||||
return ParseTraceEventKeys(stdout)
|
||||
}
|
||||
|
||||
func MissingTraceEvents(required []string, stdout string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, key := range ParseTraceEventKeys(stdout) {
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// An imported session's tool work never reaches the model.
|
||||
//
|
||||
// sessions.promptContextEvents — the filter behind both `zero exec --resume` and
|
||||
// the TUI's /resume — passes only EventMessage, EventCompaction, EventError and
|
||||
// the session-lifecycle types. EventToolCall and EventToolResult are not in that
|
||||
// list, so every file the other agent read, every command it ran and every error
|
||||
// it hit is visible in the transcript and invisible to the model continuing the
|
||||
// work. On a real 22-event import that left 2 messages and 1,155 characters of
|
||||
// context.
|
||||
//
|
||||
// This file closes that gap without touching the shared filter: while
|
||||
// translating, it records what the tools actually did and emits the result as
|
||||
// an assistant EventMessage, a type the filter already passes.
|
||||
//
|
||||
// EventCompaction also passes the filter and was the first choice, but it
|
||||
// carries a second contract on the replay side — RehydrateEvents restructures
|
||||
// the transcript around the last one — so a summary with no CompactionPayload
|
||||
// bookkeeping is hoisted to the front on resume. EventMessage passes the digest
|
||||
// with no such side effect; see noteEvent.
|
||||
//
|
||||
// Zero's own compaction was the other alternative and is strictly worse here:
|
||||
// sessions.toolPayloadPreview allow-lists id/name/toolName/status and drops
|
||||
// "arguments" and "output", so a compaction summariser learns that a Read failed
|
||||
// but never which file or why. At translation time those values are still in
|
||||
// hand.
|
||||
|
||||
// maxSummaryEventBytes keeps one summary event inside the digest's per-event
|
||||
// budget. sessions.summarizePayload truncates every event to 500 bytes, so
|
||||
// a single long summary would be sliced mid-sentence; several short ones each
|
||||
// arrive intact. The margin absorbs the payload's own framing.
|
||||
const maxSummaryEventBytes = 460
|
||||
|
||||
// maxSummaryItems bounds one line's list before it collapses to a count. A
|
||||
// session that touched ninety files should say so rather than name eleven of
|
||||
// them and imply that was all.
|
||||
const maxSummaryItems = 12
|
||||
|
||||
// activityLog accumulates what another agent's tools did during a translation.
|
||||
// It is filled from the same pass that builds the events, so it costs no extra
|
||||
// read of the transcript.
|
||||
type activityLog struct {
|
||||
cwd string
|
||||
|
||||
calls int
|
||||
failed int
|
||||
|
||||
read []string
|
||||
changed []string
|
||||
commands []string
|
||||
searches []string
|
||||
failures []string
|
||||
|
||||
// pendingPath maps a call id to the file claim it would contribute, held
|
||||
// until the call's RESULT is known. A path is committed to its bucket only
|
||||
// once a success result confirms the tool ran: a Read of a path that does
|
||||
// not exist must not appear under "Files read", and a Write whose result
|
||||
// never arrives (an interrupted final call) must not appear under "Files
|
||||
// changed" — both read as fact and are exactly the wrong thing to tell the
|
||||
// next model. A claim still pending when the transcript ends is discarded.
|
||||
pendingPath map[string]pathClaim
|
||||
|
||||
// toolCounts is the fallback when nothing could be extracted from a tool's
|
||||
// arguments: naming the tools and how often they ran is still true, where
|
||||
// guessing at a filename would not be.
|
||||
toolCounts map[string]int
|
||||
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
// pathClaim is a file path a call would contribute to a bucket, remembered until
|
||||
// its result is known and only then committed.
|
||||
type pathClaim struct {
|
||||
bucket string
|
||||
value string
|
||||
}
|
||||
|
||||
func newActivityLog(cwd string) *activityLog {
|
||||
return &activityLog{
|
||||
cwd: cwd,
|
||||
toolCounts: map[string]int{},
|
||||
seen: map[string]bool{},
|
||||
pendingPath: map[string]pathClaim{},
|
||||
}
|
||||
}
|
||||
|
||||
// commitClaim records a pending file claim now that its call has succeeded.
|
||||
// Committing on success rather than withdrawing on failure is what keeps the
|
||||
// keying correct: a successful Write of a path and a later FAILED Edit of the
|
||||
// same path each carry their own call id, so the failure simply never commits
|
||||
// and cannot erase the success — the withdraw-by-value it replaced could.
|
||||
func (log *activityLog) commitClaim(claim pathClaim) {
|
||||
list := &log.read
|
||||
if claim.bucket == "changed" {
|
||||
list = &log.changed
|
||||
}
|
||||
log.add(claim.bucket, list, claim.value)
|
||||
}
|
||||
|
||||
// add appends value to list unless an equal value is already recorded under
|
||||
// bucket. Deduplicated because agents re-read the same file repeatedly and a
|
||||
// list of forty identical paths tells the reader nothing.
|
||||
func (log *activityLog) add(bucket string, list *[]string, value string) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
key := bucket + "\x00" + trimmed
|
||||
if log.seen[key] {
|
||||
return
|
||||
}
|
||||
log.seen[key] = true
|
||||
*list = append(*list, trimmed)
|
||||
}
|
||||
|
||||
// observeCall records one tool invocation.
|
||||
//
|
||||
// Classification is by ARGUMENT KEY, not by tool name. Tool names differ across
|
||||
// agents and versions (Read/read_file/view, Bash/exec/shell), but the parameter
|
||||
// carrying a path is reliably called some spelling of "path", and a command is
|
||||
// reliably "command". Keys are allow-listed rather than sniffed: an unknown
|
||||
// schema falls back to the tool-name count instead of guessing which field is a
|
||||
// filename (repo invariant #2, and #8 — these arguments are untrusted input).
|
||||
func (log *activityLog) observeCall(callID string, name string, arguments string) {
|
||||
log.calls++
|
||||
trimmedName := strings.TrimSpace(DisplayField(name))
|
||||
if trimmedName == "" {
|
||||
trimmedName = "unknown"
|
||||
}
|
||||
|
||||
fields := map[string]any{}
|
||||
if json.Unmarshal([]byte(arguments), &fields) != nil {
|
||||
// Not a JSON object. Codex's custom_tool_call carries a bare script in
|
||||
// "input", which is a command in every sense that matters here.
|
||||
if script := strings.TrimSpace(arguments); script != "" {
|
||||
log.add("cmd", &log.commands, log.shorten(script))
|
||||
return
|
||||
}
|
||||
log.toolCounts[trimmedName]++
|
||||
return
|
||||
}
|
||||
|
||||
if command := firstStringField(fields, "command", "cmd", "script", "input"); command != "" {
|
||||
log.add("cmd", &log.commands, log.shorten(command))
|
||||
return
|
||||
}
|
||||
if pattern := firstStringField(fields, "pattern", "query", "search_query", "regex"); pattern != "" {
|
||||
log.add("search", &log.searches, log.shorten(pattern))
|
||||
return
|
||||
}
|
||||
if path := firstStringField(fields, "file_path", "filePath", "path", "notebook_path", "target_file"); path != "" {
|
||||
bucket := "read"
|
||||
if isMutatingToolName(trimmedName) {
|
||||
bucket = "changed"
|
||||
}
|
||||
// Hold the claim, do not record it yet: it is committed only when this
|
||||
// call's result confirms success (observeResult). A failed call never
|
||||
// commits, and a call whose result never arrives stays pending and is
|
||||
// dropped, so neither can claim a file it may not have touched.
|
||||
log.pendingPath[callID] = pathClaim{bucket: bucket, value: log.relative(path)}
|
||||
return
|
||||
}
|
||||
log.toolCounts[trimmedName]++
|
||||
}
|
||||
|
||||
// observeResult records a tool's outcome. A success commits the call's held
|
||||
// file claim (see observeCall); a failure drops it and is itself recorded in the
|
||||
// failures bucket — the single most useful thing to carry forward, since it is
|
||||
// what the next model would otherwise repeat.
|
||||
func (log *activityLog) observeResult(callID string, name string, status tools.Status, output string) {
|
||||
claim, hadClaim := log.pendingPath[callID]
|
||||
delete(log.pendingPath, callID)
|
||||
if status == tools.StatusOK {
|
||||
// Success confirms the call ran: only now is its path recorded.
|
||||
if hadClaim {
|
||||
log.commitClaim(claim)
|
||||
}
|
||||
return
|
||||
}
|
||||
if status != tools.StatusError {
|
||||
// A foreign format that supplies no outcome evidence is not proof of
|
||||
// success or failure. Drop its pending factual claim while preserving the
|
||||
// raw result event for history.
|
||||
return
|
||||
}
|
||||
// The call did not do what it claimed, so its pending claim is dropped
|
||||
// (never committed) and the failure itself is recorded below.
|
||||
log.failed++
|
||||
trimmedName := strings.TrimSpace(DisplayField(name))
|
||||
if trimmedName == "" {
|
||||
trimmedName = "unknown"
|
||||
}
|
||||
detail := log.shorten(output)
|
||||
if detail == "" {
|
||||
detail = "failed"
|
||||
}
|
||||
log.add("fail", &log.failures, trimmedName+": "+detail)
|
||||
}
|
||||
|
||||
// isMutatingToolName reports whether a tool that takes a path changes the file.
|
||||
// Substring matching on the name is deliberate: it holds across Write/write_file/
|
||||
// FileWrite/apply_patch without a per-agent table to keep in sync.
|
||||
func isMutatingToolName(name string) bool {
|
||||
lowered := strings.ToLower(name)
|
||||
for _, marker := range []string{"write", "edit", "patch", "create", "append", "insert", "replace", "delete", "remove"} {
|
||||
if strings.Contains(lowered, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// relative shortens an absolute path against the session's working directory,
|
||||
// so a summary reads "internal/tui/model.go" instead of consuming a third of its
|
||||
// character budget on a repeated home-directory prefix. A path outside the
|
||||
// workspace keeps its absolute form — that it lies elsewhere is the interesting
|
||||
// part.
|
||||
func (log *activityLog) relative(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
root := strings.TrimSpace(log.cwd)
|
||||
if trimmed == "" || root == "" {
|
||||
return log.shorten(trimmed)
|
||||
}
|
||||
if relative, err := filepath.Rel(root, trimmed); err == nil && !strings.HasPrefix(relative, "..") {
|
||||
return log.shorten(relative)
|
||||
}
|
||||
return log.shorten(trimmed)
|
||||
}
|
||||
|
||||
// shorten caps a single item so one long value cannot crowd out the rest of its
|
||||
// line.
|
||||
func (log *activityLog) shorten(value string) string {
|
||||
const limit = 120
|
||||
// Redact sees the complete value before this presentation copy loses later
|
||||
// lines or its tail. Truncating first can cut away the syntax that lets the
|
||||
// shared redactor recognize a credential. Keep the established first-line
|
||||
// activity policy after that complete-value pass.
|
||||
selected := firstLine(redact(value))
|
||||
// DisplayField adds the presentation-only split-credential pass after the
|
||||
// complete-value redactor has retained the established first-line boundary.
|
||||
// Run it before Fields rejoins whitespace-separated credential fragments.
|
||||
collapsed := strings.Join(strings.Fields(DisplayField(selected)), " ")
|
||||
runes := []rune(collapsed)
|
||||
if len(runes) <= limit {
|
||||
return collapsed
|
||||
}
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
|
||||
func firstLine(value string) string {
|
||||
if index := strings.IndexAny(value, "\r\n"); index >= 0 {
|
||||
return value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// firstStringField returns the first non-empty string among the named keys.
|
||||
func firstStringField(fields map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := fields[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// summaryEvents renders the log as assistant EventMessage events, one per category.
|
||||
//
|
||||
// Several small events rather than one large one, because the resume digest
|
||||
// truncates each event at 500 characters — a single combined summary would lose
|
||||
// its tail. Failures come last so they sit closest to the user's new request.
|
||||
func (log *activityLog) summaryEvents() []sessions.AppendEventInput {
|
||||
if log == nil || log.calls == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
events := []sessions.AppendEventInput{}
|
||||
headline := "Prior session activity: " + countPhrase(log.calls, "tool call")
|
||||
if log.failed > 0 {
|
||||
headline += ", " + countPhrase(log.failed, "failure")
|
||||
}
|
||||
headline += "."
|
||||
if extra := log.toolBreakdown(); extra != "" {
|
||||
headline += " " + extra
|
||||
}
|
||||
// THE BUDGET APPLIES TO THE ASSEMBLED LINE, NOT TO A PIECE OF IT. Only the
|
||||
// breakdown was capped, so the count prefix rode on top of an already
|
||||
// full-length tail: a session full of unrecognised tool names produced a
|
||||
// 500-character note, which is exactly where sessions.summarizePayload cuts —
|
||||
// the mid-sentence truncation maxSummaryEventChars exists to keep off.
|
||||
events = append(events, noteEvent(truncateToBudget(headline, maxSummaryEventBytes)))
|
||||
|
||||
for _, section := range []struct {
|
||||
label string
|
||||
items []string
|
||||
}{
|
||||
{"Files read", log.read},
|
||||
{"Files changed", log.changed},
|
||||
{"Commands run", log.commands},
|
||||
{"Searched for", log.searches},
|
||||
{"Failures", log.failures},
|
||||
} {
|
||||
if line := summaryLine(section.label, section.items); line != "" {
|
||||
events = append(events, noteEvent(line))
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// toolBreakdown names tools whose arguments yielded nothing, so an unrecognised
|
||||
// schema degrades to "Also: exec x4" rather than to silence.
|
||||
//
|
||||
// It returns the full list and does NOT cap it: the cap belongs to the caller,
|
||||
// which appends this to a count prefix. Capping here as well would truncate
|
||||
// twice and leave an ellipsis inside the line as well as at its end.
|
||||
func (log *activityLog) toolBreakdown() string {
|
||||
if len(log.toolCounts) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(log.toolCounts))
|
||||
for name := range log.toolCounts {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
parts := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
count := log.toolCounts[name]
|
||||
if count > 1 {
|
||||
parts = append(parts, name+" x"+itoaEvents(count))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, name)
|
||||
}
|
||||
return "Also: " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// summaryLine renders one category, collapsing to a count once the list grows
|
||||
// past what is useful or past the character budget. The overflow is always
|
||||
// stated: a truncated list that looks complete is how a reader concludes the
|
||||
// other agent touched four files when it touched forty.
|
||||
func summaryLine(label string, items []string) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
kept := items
|
||||
dropped := 0
|
||||
if len(kept) > maxSummaryItems {
|
||||
dropped = len(kept) - maxSummaryItems
|
||||
kept = kept[:maxSummaryItems]
|
||||
}
|
||||
for {
|
||||
prefix := label + ": "
|
||||
line := prefix + strings.Join(kept, ", ")
|
||||
suffix := ""
|
||||
if dropped > 0 {
|
||||
suffix = " (+" + itoaEvents(dropped) + " more)"
|
||||
line += suffix
|
||||
}
|
||||
if len(line) <= maxSummaryEventBytes {
|
||||
return line
|
||||
}
|
||||
if len(kept) <= 1 {
|
||||
itemBudget := maxSummaryEventBytes - len(prefix) - len(suffix)
|
||||
if itemBudget <= 0 {
|
||||
return truncateToBudget(prefix+suffix, maxSummaryEventBytes)
|
||||
}
|
||||
return prefix + truncateToBudget(kept[0], itemBudget) + suffix
|
||||
}
|
||||
dropped++
|
||||
kept = kept[:len(kept)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func truncateToBudget(value string, budget int) string {
|
||||
if budget <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= budget {
|
||||
return value
|
||||
}
|
||||
const ellipsis = "…"
|
||||
if budget < len(ellipsis) {
|
||||
return strings.Repeat(".", budget)
|
||||
}
|
||||
end := budget - len(ellipsis)
|
||||
for end > 0 && !utf8.RuneStart(value[end]) {
|
||||
end--
|
||||
}
|
||||
return value[:end] + ellipsis
|
||||
}
|
||||
|
||||
func countPhrase(count int, noun string) string {
|
||||
if count == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return itoaEvents(count) + " " + noun + "s"
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func summaryTexts(t *testing.T, events []sessions.AppendEventInput) []string {
|
||||
t.Helper()
|
||||
out := []string{}
|
||||
for _, event := range events {
|
||||
// Activity summaries are now assistant messages carrying the summary
|
||||
// marker (see noteEvent), not EventCompaction.
|
||||
if !NoteEventIsSummary(event.Payload) {
|
||||
continue
|
||||
}
|
||||
out = append(out, str(t, event, "content"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinedSummary(t *testing.T, events []sessions.AppendEventInput) string {
|
||||
t.Helper()
|
||||
return strings.Join(summaryTexts(t, events), "\n")
|
||||
}
|
||||
|
||||
// claudeToolLines builds a transcript with one tool_use/tool_result pair.
|
||||
func claudeToolLines(id, name, arguments, output string, failed bool) []string {
|
||||
isError := "false"
|
||||
if failed {
|
||||
isError = "true"
|
||||
}
|
||||
return []string{
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"` + id +
|
||||
`","name":"` + name + `","input":` + arguments + `}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"` + id +
|
||||
`","is_error":` + isError + `,"content":"` + output + `"}]}}`,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSummaryNamesFilesCommandsAndSearches(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Read", `{"file_path":"/w/parser.go"}`, "package main", false)...)
|
||||
lines = append(lines, claudeToolLines("t2", "Edit", `{"file_path":"/w/lexer.go"}`, "ok", false)...)
|
||||
lines = append(lines, claudeToolLines("t3", "Bash", `{"command":"go test ./..."}`, "PASS", false)...)
|
||||
lines = append(lines, claudeToolLines("t4", "Grep", `{"pattern":"handleResume"}`, "3 hits", false)...)
|
||||
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary := joinedSummary(t, events)
|
||||
|
||||
for _, want := range []string{
|
||||
"4 tool calls",
|
||||
"Files read: parser.go", // relative to cwd, not the absolute path
|
||||
"Files changed: lexer.go", // Edit is classified as mutating
|
||||
"Commands run: go test ./...",
|
||||
"Searched for: handleResume",
|
||||
} {
|
||||
if !strings.Contains(summary, want) {
|
||||
t.Errorf("summary missing %q:\n%s", want, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAFailedCallDoesNotClaimItReadTheFile is the correctness fix the real
|
||||
// corpus prompted: a Read of a path that does not exist was still listed under
|
||||
// "Files read", which the next model would take as fact.
|
||||
func TestAFailedCallDoesNotClaimItReadTheFile(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Read", `{"file_path":"/home/wrong/parser.go"}`,
|
||||
"File does not exist.", true)...)
|
||||
lines = append(lines, claudeToolLines("t2", "Read", `{"file_path":"/w/parser.go"}`, "package main", false)...)
|
||||
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary := joinedSummary(t, events)
|
||||
|
||||
if strings.Contains(summary, "/home/wrong/parser.go") && strings.Contains(summary, "Files read") {
|
||||
for _, line := range summaryTexts(t, events) {
|
||||
if strings.HasPrefix(line, "Files read") && strings.Contains(line, "/home/wrong/parser.go") {
|
||||
t.Errorf("a path whose read FAILED is listed as read:\n%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(summary, "Files read: parser.go") {
|
||||
t.Errorf("the successful read was lost:\n%s", summary)
|
||||
}
|
||||
// The failure itself must still be reported — that is the most useful line.
|
||||
if !strings.Contains(summary, "Failures") || !strings.Contains(summary, "File does not exist") {
|
||||
t.Errorf("the failure was not reported:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEverySummaryEventSurvivesTheDigestIntact is the constraint that decided
|
||||
// the shape of this feature. sessions.summarizePayload truncates each event at
|
||||
// 500 bytes, so one combined summary would lose its tail; each event must fit.
|
||||
func TestEverySummaryEventSurvivesTheDigestIntact(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
for i := 0; i < 40; i++ {
|
||||
lines = append(lines, claudeToolLines(
|
||||
"t"+itoa(i), "Read",
|
||||
`{"file_path":"/w/a/very/long/directory/name/that/eats/budget/file`+itoa(i)+`.go"}`,
|
||||
"ok", false)...)
|
||||
}
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries := summaryTexts(t, events)
|
||||
if len(summaries) == 0 {
|
||||
t.Fatal("no summary events produced")
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
if length := len(summary); length > maxSummaryEventBytes {
|
||||
t.Errorf("summary event is %d bytes, over the %d budget — it will be cut "+
|
||||
"mid-sentence by the resume digest:\n%s", length, maxSummaryEventBytes, summary)
|
||||
}
|
||||
}
|
||||
// And the overflow must be stated, not silently dropped.
|
||||
if !strings.Contains(strings.Join(summaries, "\n"), "more)") {
|
||||
t.Errorf("40 files collapsed to a short list with no overflow note:\n%s",
|
||||
strings.Join(summaries, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultibyteSummarySurvivesFormatExecPromptIntact(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
lines := []string{`{"type":"user","cwd":"/w","sessionId":"multi","message":{"role":"user","content":"continue"}}`}
|
||||
for i := 0; i < 20; i++ {
|
||||
name := strings.Repeat("界", 24) + itoa(i) + ".go"
|
||||
lines = append(lines, claudeToolLines("t"+itoa(i), "Read", `{"file_path":"/w/`+name+`"}`, "ok", false)...)
|
||||
}
|
||||
writeFile(t, filepath.Join(home, ".claude", "projects", "-w", "multi.jsonl"), strings.Join(lines, "\n")+"\n")
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := Import(store, ClaudeCode(testEnv(home, nil)), "multi", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := store.ReadEvents(result.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fileSummary string
|
||||
for _, event := range events {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil || !NoteEventIsSummary(payload) {
|
||||
continue
|
||||
}
|
||||
content, _ := payload["content"].(string)
|
||||
if strings.HasPrefix(content, "Files read:") {
|
||||
fileSummary = content
|
||||
}
|
||||
}
|
||||
if fileSummary == "" || !strings.Contains(fileSummary, "more)") {
|
||||
t.Fatalf("multibyte file summary lost its overflow disclosure: %q", fileSummary)
|
||||
}
|
||||
if len(fileSummary) > maxSummaryEventBytes {
|
||||
t.Fatalf("summary is %d bytes, want at most %d: %q", len(fileSummary), maxSummaryEventBytes, fileSummary)
|
||||
}
|
||||
prepared, err := sessions.PrepareExec(sessions.PrepareExecOptions{Store: store, Resume: result.Session.SessionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := sessions.FormatExecPrompt("what remains?", prepared)
|
||||
if !strings.Contains(prompt, fileSummary) {
|
||||
t.Fatalf("FormatExecPrompt truncated a producer-approved summary:\nsummary=%q\nprompt=%s", fileSummary, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryLinePreservesOverflowDisclosureWhenOneItemExhaustsTheBudget(t *testing.T) {
|
||||
items := []string{strings.Repeat("界", maxSummaryEventBytes)}
|
||||
for i := 0; i < maxSummaryItems+4; i++ {
|
||||
items = append(items, "file"+itoa(i)+".go")
|
||||
}
|
||||
line := summaryLine("Files read", items)
|
||||
if len(line) > maxSummaryEventBytes || !utf8.ValidString(line) {
|
||||
t.Fatalf("bounded summary is invalid or oversized: bytes=%d valid=%v %q", len(line), utf8.ValidString(line), line)
|
||||
}
|
||||
if !strings.Contains(line, "more)") {
|
||||
t.Fatalf("a long first item erased the overflow disclosure: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateToBudgetKeepsUTF8ValidAtEverySmallBudget(t *testing.T) {
|
||||
for budget := 0; budget <= 4; budget++ {
|
||||
got := truncateToBudget("界界", budget)
|
||||
if len(got) > budget || !utf8.ValidString(got) {
|
||||
t.Errorf("budget %d produced bytes=%d valid=%v %q", budget, len(got), utf8.ValidString(got), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheSummaryReachesTheModel is the whole point: these events must survive
|
||||
// sessions.promptContextEvents, the filter that drops tool events.
|
||||
func TestTheSummaryReachesTheModel(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
lines := []string{`{"type":"user","cwd":"/w","sessionId":"s1","message":{"role":"user","content":"fix the parser"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Read", `{"file_path":"/w/parser.go"}`, "package main", false)...)
|
||||
lines = append(lines, claudeToolLines("t2", "Bash", `{"command":"go test ./parser"}`, "FAIL", true)...)
|
||||
writeFile(t, filepath.Join(home, ".claude", "projects", "-w", "s1.jsonl"),
|
||||
strings.Join(lines, "\n")+"\n")
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := Import(store, ClaudeCode(testEnv(home, nil)), "s1", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, err := sessions.PrepareExec(sessions.PrepareExecOptions{Store: store, Resume: result.Session.SessionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sessions.FormatExecPrompt("what is left?", prepared)
|
||||
|
||||
// Before this feature the digest held only the two prose messages. These
|
||||
// facts existed solely in tool events, which never reach the model.
|
||||
for _, want := range []string{
|
||||
"tool calls",
|
||||
"parser.go",
|
||||
"go test ./parser",
|
||||
"Failures",
|
||||
} {
|
||||
if !strings.Contains(digest, want) {
|
||||
t.Errorf("the resume digest is missing %q — the summary is not reaching "+
|
||||
"the model:\n%s", want, digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASessionWithNoToolCallsGetsNoSummary(t *testing.T) {
|
||||
events, err := translateFamily1At("", writeTranscript(t,
|
||||
`{"type":"user","cwd":"/w","message":{"role":"user","content":"hello"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
|
||||
), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := summaryTexts(t, events); len(got) != 0 {
|
||||
t.Errorf("got %d summary events for a conversation with no tools, want none: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnUnknownToolSchemaDegradesToACount covers the case this design must not
|
||||
// get wrong: an agent whose arguments use names we have never seen. Naming the
|
||||
// tool and its count is true; guessing which field held a filename would not be.
|
||||
func TestAnUnknownToolSchemaDegradesToACount(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "MysteryTool", `{"wibble":"/w/secret.go","flim":3}`, "ok", false)...)
|
||||
lines = append(lines, claudeToolLines("t2", "MysteryTool", `{"wibble":"/w/other.go"}`, "ok", false)...)
|
||||
|
||||
events, _ := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
summary := joinedSummary(t, events)
|
||||
|
||||
if !strings.Contains(summary, "MysteryTool x2") {
|
||||
t.Errorf("an unrecognised schema should still be counted:\n%s", summary)
|
||||
}
|
||||
for _, guessed := range []string{"secret.go", "other.go", "Files read"} {
|
||||
if strings.Contains(summary, guessed) {
|
||||
t.Errorf("summary guessed %q out of an unknown argument schema:\n%s", guessed, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedWorkIsNotListedRepeatedly(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
for i := 0; i < 8; i++ {
|
||||
lines = append(lines, claudeToolLines("t"+itoa(i), "Read", `{"file_path":"/w/same.go"}`, "ok", false)...)
|
||||
}
|
||||
events, _ := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
summary := joinedSummary(t, events)
|
||||
|
||||
if count := strings.Count(summary, "same.go"); count != 1 {
|
||||
t.Errorf("same.go appears %d times, want 1 — re-reads must dedupe:\n%s", count, summary)
|
||||
}
|
||||
// The call count is still the true one.
|
||||
if !strings.Contains(summary, "8 tool calls") {
|
||||
t.Errorf("dedupe must not change the call count:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretsInToolArgumentsAreRedacted: the summary is built from another
|
||||
// program's logs, so it goes through the same chokepoint as every other
|
||||
// imported string.
|
||||
func TestSecretsInToolArgumentsAreRedacted(t *testing.T) {
|
||||
const leaked = "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLL"
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Bash", `{"command":"export K=`+leaked+`"}`, "ok", false)...)
|
||||
lines = append(lines, claudeToolLines("t2", "Bash", `{"command":"run-command"}`, "stderr: "+leaked, true)...)
|
||||
|
||||
events, _ := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
encoded, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), leaked) {
|
||||
t.Errorf("a secret from a tool argument survived into the summary:\n%s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsOutsideTheWorkspaceKeepTheirAbsoluteForm(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Read", `{"file_path":"/w/inside.go"}`, "ok", false)...)
|
||||
lines = append(lines, claudeToolLines("t2", "Read", `{"file_path":"/elsewhere/outside.go"}`, "ok", false)...)
|
||||
|
||||
events, _ := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
summary := joinedSummary(t, events)
|
||||
|
||||
if !strings.Contains(summary, "inside.go") || strings.Contains(summary, "/w/inside.go") {
|
||||
t.Errorf("a file in the workspace should be shown relative:\n%s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "/elsewhere/outside.go") {
|
||||
t.Errorf("a file outside the workspace should keep its absolute path — that "+
|
||||
"it is elsewhere is the interesting part:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryEventsComeLastSoTheySitNearestTheNewRequest(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Read", `{"file_path":"/w/a.go"}`, "ok", false)...)
|
||||
|
||||
events, _ := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if len(events) < 2 {
|
||||
t.Fatal("expected conversation events plus a summary")
|
||||
}
|
||||
if !NoteEventIsSummary(events[len(events)-1].Payload) {
|
||||
t.Errorf("last event is %s, want the summary last so it survives the "+
|
||||
"80-event window and reads as a footer", events[len(events)-1].Type)
|
||||
}
|
||||
// And the conversation itself still comes first.
|
||||
if events[0].Type != sessions.EventMessage {
|
||||
t.Errorf("first event is %s, want the original conversation to lead", events[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASuccessfulWriteSurvivesALaterFailedEditOfTheSamePath covers the
|
||||
// coarse-keying fix: the old code withdrew a failed call's claim BY VALUE, so a
|
||||
// failed Edit erased the record of an earlier successful Write to the same path
|
||||
// and the summary reported no files changed although the file was rewritten.
|
||||
func TestASuccessfulWriteSurvivesALaterFailedEditOfTheSamePath(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
lines = append(lines, claudeToolLines("t1", "Write", `{"file_path":"/w/config.yaml"}`, "wrote 40 lines", false)...)
|
||||
lines = append(lines, claudeToolLines("t2", "Edit", `{"file_path":"/w/config.yaml"}`, "string not found", true)...)
|
||||
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary := joinedSummary(t, events)
|
||||
if !strings.Contains(summary, "Files changed: config.yaml") {
|
||||
t.Errorf("the successful Write was erased by the later failed Edit of the same path:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInterruptedWriteWithNoResultDoesNotClaimTheFile covers the
|
||||
// no-result fix: a tool call whose result never arrives (the session stopped
|
||||
// mid-call) must not report its file as changed — we never learned it ran.
|
||||
func TestAnInterruptedWriteWithNoResultDoesNotClaimTheFile(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
// The tool_use with no matching tool_result — the transcript ends here.
|
||||
lines = append(lines, claudeToolLines("t1", "Write", `{"file_path":"/w/config.yaml"}`, "", false)[0])
|
||||
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range summaryTexts(t, events) {
|
||||
if strings.HasPrefix(line, "Files changed") && strings.Contains(line, "config.yaml") {
|
||||
t.Errorf("an interrupted write with no result is claimed as a file changed:\n%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE BUDGET HAS TO BIND ON THE ASSEMBLED LINE. The previous implementation
|
||||
// capped the tool breakdown and then prepended the call/failure counts, so the
|
||||
// headline left the budget by exactly the length of that prefix. The existing
|
||||
// budget test
|
||||
// never caught it because its tools all carry a recognised "file_path", which
|
||||
// routes them to the file buckets and leaves the breakdown empty — the overflow
|
||||
// only appears once the arguments are a schema this package does not know, which
|
||||
// is the fallback path the breakdown exists for.
|
||||
func TestTheActivityHeadlineIsTruncatedAfterItIsAssembled(t *testing.T) {
|
||||
lines := []string{`{"type":"user","cwd":"/w","message":{"role":"user","content":"go"}}`}
|
||||
for i := 0; i < 60; i++ {
|
||||
lines = append(lines, claudeToolLines(
|
||||
"t"+itoa(i), "unrecognised_tool_"+itoa(i), `{"unknown_argument_name":"x"}`, "ok", false)...)
|
||||
}
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries := summaryTexts(t, events)
|
||||
if len(summaries) == 0 {
|
||||
t.Fatal("no summary events produced")
|
||||
}
|
||||
|
||||
headline := summaries[0]
|
||||
// The prefix has to be there, or the breakdown alone would satisfy the budget
|
||||
// for the wrong reason — that was the passing state before the fix.
|
||||
if !strings.HasPrefix(headline, "Prior session activity: 60 tool calls.") {
|
||||
t.Fatalf("the headline is not the count-prefixed line this test is about:\n%s", headline)
|
||||
}
|
||||
if !strings.Contains(headline, "Also: unrecognised_tool_") {
|
||||
t.Fatalf("the breakdown never ran, so nothing could overflow:\n%s", headline)
|
||||
}
|
||||
if length := len(headline); length != maxSummaryEventBytes {
|
||||
t.Errorf("headline is %d bytes, want it cut to exactly the %d budget:\n%s",
|
||||
length, maxSummaryEventBytes, headline)
|
||||
}
|
||||
// Cut, not merely short: the ellipsis is what tells a reader the list goes on.
|
||||
if !strings.HasSuffix(headline, "…") {
|
||||
t.Errorf("an over-long headline was truncated without saying so:\n%s", headline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityItemsRedactCompleteValuesBeforeShortening(t *testing.T) {
|
||||
secret := "ghp_" + strings.Repeat("Q", 36)
|
||||
for _, presented := range []string{
|
||||
secret,
|
||||
"ghp_ " + strings.Repeat("Q", 36),
|
||||
} {
|
||||
log := newActivityLog("/workspace")
|
||||
log.observeCall("call", "bash", `{"command":"`+strings.Repeat("x", 105)+` `+presented+`"}`)
|
||||
events := log.summaryEvents()
|
||||
joined := ""
|
||||
for _, event := range events {
|
||||
joined += str(t, event, "content") + "\n"
|
||||
}
|
||||
if strings.Contains(joined, secret) || !strings.Contains(joined, "[REDACTED]") {
|
||||
t.Fatalf("activity summary exposed a secret after shortening:\n%s", joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// TestImportedControlBytesAreStripped pins the terminal-injection fix: a foreign
|
||||
// transcript is attacker-influenced, and an ESC or NUL in a message must not
|
||||
// survive into a transcript line or picker row (the #835/#876 class). The
|
||||
// content is built with Go escapes and JSON-encoded so the transcript carries
|
||||
// the real control bytes.
|
||||
func TestImportedControlBytesAreStripped(t *testing.T) {
|
||||
malicious := "before\x1b[2J\x1b[1;1H FORGED \x00\x07\r after"
|
||||
line, err := json.Marshal(map[string]any{
|
||||
"type": "user",
|
||||
"message": map[string]any{"role": "user", "content": malicious},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := writeTranscript(t, string(line))
|
||||
|
||||
events, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Inspect the payload strings DIRECTLY, not a json.Marshal of the events —
|
||||
// JSON encoding would escape a surviving control byte to "" and hide it.
|
||||
var contentSeen string
|
||||
for _, event := range events {
|
||||
payload, ok := event.Payload.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, field := range payload {
|
||||
s, ok := field.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(s, "\x1b\x00\x07\r") {
|
||||
t.Errorf("a control byte survived translation into a payload string: %q", s)
|
||||
}
|
||||
if strings.Contains(s, "before") {
|
||||
contentSeen = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if contentSeen == "" || !strings.Contains(contentSeen, "after") {
|
||||
t.Errorf("stripping removed visible text, not just control bytes: %q", contentSeen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripControlKeepsTabAndNewline guards the one carve-out: transcripts
|
||||
// legitimately carry tab and newline, and dropping them would mangle real text.
|
||||
func TestStripControlKeepsTabAndNewline(t *testing.T) {
|
||||
got, _ := stripControlWithBoundaries("a\tb\nc\rd\x1be\x00f")
|
||||
if got != "a\tb\ncdef" {
|
||||
t.Errorf("stripControl = %q, want tab and newline kept and ESC/NUL dropped", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivitySummaryIsAMessageNotACompaction pins the replay-contract fix.
|
||||
// EventCompaction drives RehydrateEvents, which hoists a bookkeeping-less summary
|
||||
// to the front of the transcript; the activity summary must not be that type.
|
||||
func TestActivitySummaryIsAMessageNotACompaction(t *testing.T) {
|
||||
note := noteEvent("Prior session activity: 1 tool call.")
|
||||
if note.Type == sessions.EventCompaction {
|
||||
t.Fatal("activity summary is EventCompaction — RehydrateEvents will hoist it to the transcript front")
|
||||
}
|
||||
if note.Type != sessions.EventMessage {
|
||||
t.Fatalf("activity summary type = %s, want EventMessage", note.Type)
|
||||
}
|
||||
if !NoteEventIsSummary(note.Payload) {
|
||||
t.Fatal("activity summary carries no marker, so nothing can tell it from a real assistant turn")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStructuralFieldsAreRedacted covers CodeRabbit's finding: role, name, and
|
||||
// toolCallId come from the foreign transcript too, so a credential hidden in any
|
||||
// of them must be redacted, not merely stripped of control bytes.
|
||||
func TestStructuralFieldsAreRedacted(t *testing.T) {
|
||||
secret := "sk-ant-api03-" + strings.Repeat("A", 40)
|
||||
identities := &importCallIdentities{}
|
||||
events := []sessions.AppendEventInput{
|
||||
messageEvent(secret, "hi"), // malicious role
|
||||
toolCallEvent(identities, secret, secret, "{}"), // malicious tool name + call id
|
||||
toolResultEvent(identities, secret, secret, "ok", "out"), // malicious tool name + result id
|
||||
}
|
||||
encoded, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), secret) {
|
||||
t.Errorf("a secret in a structural field (role/name/toolCallId) survived translation:\n%s", encoded)
|
||||
}
|
||||
// Redaction is deterministic, so the call and its result must still pair up.
|
||||
call := events[1].Payload.(map[string]any)["toolCallId"].(string)
|
||||
result := events[2].Payload.(map[string]any)["toolCallId"].(string)
|
||||
if call == "" || call != result {
|
||||
t.Errorf("redacted call/result ids diverged and broke pairing: %q vs %q", call, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// discoveryTTL is how long a workspace's discovery result is reused.
|
||||
//
|
||||
// Discovery costs 50-300ms on a working machine — a bounded head read of every
|
||||
// transcript across four stores. That is affordable once, but /resume is opened,
|
||||
// dismissed and reopened constantly, and paying it on each keypress makes the
|
||||
// TUI hitch every time.
|
||||
//
|
||||
// Ten seconds is chosen to be shorter than a person can act on the staleness. A
|
||||
// session finished in another terminal appears on the next open rather than
|
||||
// this one; the alternative — no cache — makes every open hitch to avoid a case
|
||||
// nobody notices.
|
||||
const discoveryTTL = 10 * time.Second
|
||||
|
||||
type discoveryEntry struct {
|
||||
sessions []ForeignSession
|
||||
problems []error
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
discoveryMu sync.Mutex
|
||||
discoveryCache = map[string]discoveryEntry{}
|
||||
discoveryEpoch uint64
|
||||
// discoveryNow is the clock, swapped in tests so TTL expiry is exercised
|
||||
// without sleeping.
|
||||
discoveryNow = time.Now
|
||||
discoverAll = DiscoverAll
|
||||
)
|
||||
|
||||
// DiscoverAllCached is DiscoverAll with a short per-workspace memo. Callers on a
|
||||
// UI path should prefer it; a one-shot CLI command should not, since a fresh
|
||||
// process has nothing cached and the result would only ever be stale.
|
||||
func DiscoverAllCached(env Env, cwd string) ([]ForeignSession, []error) {
|
||||
discoveryMu.Lock()
|
||||
now := discoveryNow()
|
||||
if entry, ok := discoveryCache[cwd]; ok && now.Sub(entry.at) < discoveryTTL {
|
||||
// Copy: callers sort and filter the slice they are handed, and a shared
|
||||
// backing array would let one caller reorder another's results.
|
||||
discoveryMu.Unlock()
|
||||
return append([]ForeignSession{}, entry.sessions...), entry.problems
|
||||
}
|
||||
epoch := discoveryEpoch
|
||||
discoveryMu.Unlock()
|
||||
|
||||
// Discovery reads several external stores and can take hundreds of
|
||||
// milliseconds. It must not hold the global cache lock: a miss for one
|
||||
// workspace cannot stall a valid hit for another workspace.
|
||||
found, problems := discoverAll(env, cwd)
|
||||
discoveryMu.Lock()
|
||||
defer discoveryMu.Unlock()
|
||||
// Prefer a value another concurrent miss already published. If the cache was
|
||||
// invalidated while discovery ran, return this result to the current caller
|
||||
// but do not repopulate the cache with a pre-invalidation snapshot.
|
||||
now = discoveryNow()
|
||||
if entry, ok := discoveryCache[cwd]; ok && now.Sub(entry.at) < discoveryTTL {
|
||||
return append([]ForeignSession{}, entry.sessions...), entry.problems
|
||||
}
|
||||
if epoch == discoveryEpoch {
|
||||
discoveryCache[cwd] = discoveryEntry{sessions: found, problems: problems, at: now}
|
||||
}
|
||||
return append([]ForeignSession{}, found...), problems
|
||||
}
|
||||
|
||||
// InvalidateDiscovery drops the memo so the next discovery re-reads the stores.
|
||||
// Called after an import, which changes which sessions are still un-imported.
|
||||
func InvalidateDiscovery() {
|
||||
discoveryMu.Lock()
|
||||
defer discoveryMu.Unlock()
|
||||
discoveryEpoch++
|
||||
discoveryCache = map[string]discoveryEntry{}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withFakeClock(t *testing.T) *time.Time {
|
||||
t.Helper()
|
||||
clock := time.Date(2026, 8, 8, 10, 0, 0, 0, time.UTC)
|
||||
previous := discoveryNow
|
||||
discoveryNow = func() time.Time { return clock }
|
||||
InvalidateDiscovery()
|
||||
t.Cleanup(func() {
|
||||
discoveryNow = previous
|
||||
InvalidateDiscovery()
|
||||
})
|
||||
return &clock
|
||||
}
|
||||
|
||||
func storeWithOneSession(t *testing.T) Env {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
writeFile(t, filepath.Join(home, ".claude", "projects", "-w", "a.jsonl"),
|
||||
`{"type":"user","cwd":"/w","sessionId":"a","message":{"role":"user","content":"hi"}}`+"\n")
|
||||
return testEnv(home, nil)
|
||||
}
|
||||
|
||||
func TestASecondDiscoveryInsideTheWindowDoesNotReReadTheStores(t *testing.T) {
|
||||
clock := withFakeClock(t)
|
||||
env := storeWithOneSession(t)
|
||||
|
||||
first, _ := DiscoverAllCached(env, "/w")
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("got %d sessions, want 1", len(first))
|
||||
}
|
||||
|
||||
// Delete the store outright. A cached answer still returns the session;
|
||||
// a re-read could not.
|
||||
env2 := testEnv(t.TempDir(), nil)
|
||||
*clock = clock.Add(discoveryTTL - time.Second)
|
||||
cached, _ := DiscoverAllCached(env2, "/w")
|
||||
if len(cached) != 1 {
|
||||
t.Errorf("got %d sessions inside the TTL, want the memoised 1", len(cached))
|
||||
}
|
||||
|
||||
*clock = clock.Add(2 * time.Second) // now past the TTL
|
||||
fresh, _ := DiscoverAllCached(env2, "/w")
|
||||
if len(fresh) != 0 {
|
||||
t.Errorf("got %d sessions after the TTL, want a fresh (empty) read", len(fresh))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachWorkspaceIsMemoisedSeparately(t *testing.T) {
|
||||
withFakeClock(t)
|
||||
env := storeWithOneSession(t)
|
||||
|
||||
if got, _ := DiscoverAllCached(env, "/w"); len(got) != 1 {
|
||||
t.Fatalf("/w got %d, want 1", len(got))
|
||||
}
|
||||
// A different workspace must not be served /w's answer.
|
||||
if got, _ := DiscoverAllCached(env, "/elsewhere"); len(got) != 0 {
|
||||
t.Errorf("/elsewhere got %d sessions, want 0 — the memo is keyed by workspace", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidatingForcesAReRead(t *testing.T) {
|
||||
withFakeClock(t)
|
||||
env := storeWithOneSession(t)
|
||||
if got, _ := DiscoverAllCached(env, "/w"); len(got) != 1 {
|
||||
t.Fatalf("got %d, want 1", len(got))
|
||||
}
|
||||
InvalidateDiscovery()
|
||||
empty := testEnv(t.TempDir(), nil)
|
||||
if got, _ := DiscoverAllCached(empty, "/w"); len(got) != 0 {
|
||||
t.Errorf("got %d after invalidation, want a fresh read", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallersCannotReorderEachOthersResults covers the aliasing bug a memo
|
||||
// invites: handing every caller the same backing array lets one caller's sort
|
||||
// or filter mutate what the next one sees.
|
||||
func TestCallersCannotReorderEachOthersResults(t *testing.T) {
|
||||
withFakeClock(t)
|
||||
home := t.TempDir()
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
writeFile(t, filepath.Join(home, ".claude", "projects", "-w", id+".jsonl"),
|
||||
`{"type":"user","cwd":"/w","sessionId":"`+id+`","message":{"role":"user","content":"hi"}}`+"\n")
|
||||
}
|
||||
env := testEnv(home, nil)
|
||||
|
||||
first, _ := DiscoverAllCached(env, "/w")
|
||||
if len(first) != 3 {
|
||||
t.Fatalf("got %d, want 3", len(first))
|
||||
}
|
||||
for i := range first {
|
||||
first[i].ID = "clobbered"
|
||||
}
|
||||
|
||||
second, _ := DiscoverAllCached(env, "/w")
|
||||
for _, session := range second {
|
||||
if session.ID == "clobbered" {
|
||||
t.Fatal("one caller's mutation reached the next caller's results")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowMissDoesNotBlockAnotherWorkspacesCacheHit(t *testing.T) {
|
||||
withFakeClock(t)
|
||||
discoveryMu.Lock()
|
||||
discoveryCache["/cached"] = discoveryEntry{
|
||||
sessions: []ForeignSession{{ID: "cached"}},
|
||||
at: discoveryNow(),
|
||||
}
|
||||
discoveryMu.Unlock()
|
||||
|
||||
previous := discoverAll
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
discoverAll = func(Env, string) ([]ForeignSession, []error) {
|
||||
close(started)
|
||||
<-release
|
||||
return []ForeignSession{{ID: "slow"}}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverAll = previous
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
DiscoverAllCached(Env{}, "/slow")
|
||||
}()
|
||||
<-started
|
||||
|
||||
hit := make(chan []ForeignSession, 1)
|
||||
go func() {
|
||||
found, _ := DiscoverAllCached(Env{}, "/cached")
|
||||
hit <- found
|
||||
}()
|
||||
select {
|
||||
case found := <-hit:
|
||||
if len(found) != 1 || found[0].ID != "cached" {
|
||||
t.Fatalf("cache hit = %+v, want cached session", found)
|
||||
}
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
t.Fatal("a slow miss held the global cache lock and blocked an unrelated hit")
|
||||
}
|
||||
close(release)
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// codex reads Codex's rollout transcripts.
|
||||
//
|
||||
// Same idea as family 1, two differences that stop it sharing the code:
|
||||
//
|
||||
// - The store is partitioned by DATE, not by working directory:
|
||||
// ~/.codex/sessions/YYYY/MM/DD/rollout-<iso>-<uuid>.jsonl. There is no slug
|
||||
// to narrow with, so scoping to a workspace always means indexing the lot —
|
||||
// affordable only because indexing is a bounded head read.
|
||||
// - Every record wraps its real content in a "payload" object, and the record
|
||||
// kind that matters is payload.type rather than the outer type.
|
||||
type codex struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// Codex reads Codex's rollout transcripts.
|
||||
func Codex(env Env) Adapter {
|
||||
return codex{root: codexRoot(env)}
|
||||
}
|
||||
|
||||
func (adapter codex) Name() string { return "codex" }
|
||||
|
||||
// transcripts lists rollout files at exactly three levels below the root
|
||||
// (year/month/day). Fixed depth for the same reason as everywhere else in this
|
||||
// package: ~/.codex/auth.json holds a live OPENAI_API_KEY and an OAuth token,
|
||||
// and a walk from the codex home would reach it.
|
||||
func (adapter codex) transcripts() []string {
|
||||
if strings.TrimSpace(adapter.root) == "" {
|
||||
return nil
|
||||
}
|
||||
return globTranscripts(adapter.root, filepath.Join(adapter.root, "*", "*", "*", "rollout-*"+transcriptExt))
|
||||
}
|
||||
|
||||
func (adapter codex) Discover(cwd string) ([]ForeignSession, error) {
|
||||
found := []ForeignSession{}
|
||||
for _, path := range adapter.transcripts() {
|
||||
session, ok := indexCodexTranscript(adapter.Name(), adapter.root, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cwd) != "" && !sameDir(cwd, session.Cwd) {
|
||||
continue
|
||||
}
|
||||
found = append(found, session)
|
||||
}
|
||||
sortByRecency(found)
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (adapter codex) Read(source ForeignSession, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
if source.Agent != adapter.Name() || source.ID != codexID(source.Path) {
|
||||
return nil, errors.New("agentsessions: selected session does not belong to codex")
|
||||
}
|
||||
// One handle from identity check to final check: see openSelectedSource.
|
||||
file, err := openSelectedSource(adapter.root, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
events, err := translateCodex(file, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSourceHandle(file, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
type codexRecord struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Payload codexPayload `json:"payload"`
|
||||
}
|
||||
|
||||
// codexPayload is the union of the payload shapes this adapter reads:
|
||||
// session_meta, turn_context, and the several response_item kinds.
|
||||
//
|
||||
// Any field whose TYPE differs between those shapes must be json.RawMessage and
|
||||
// be decoded at the point of use. "summary" is the cautionary example: on a
|
||||
// reasoning item it is an array of blocks, on turn_context it is the plain
|
||||
// string "auto". Typing it as []codexBlock made encoding/json reject the entire
|
||||
// turn_context record — so the adapter silently lost the model id on every
|
||||
// session, with no error anywhere, because one unrelated field disagreed.
|
||||
type codexPayload struct {
|
||||
// session_meta
|
||||
SessionID string `json:"session_id"`
|
||||
Cwd string `json:"cwd"`
|
||||
// turn_context
|
||||
Model string `json:"model"`
|
||||
// response_item
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []codexBlock `json:"content"`
|
||||
Summary json.RawMessage `json:"summary"`
|
||||
Name string `json:"name"`
|
||||
CallID string `json:"call_id"`
|
||||
Arguments string `json:"arguments"`
|
||||
Input string `json:"input"`
|
||||
Output json.RawMessage `json:"output"`
|
||||
}
|
||||
|
||||
// codexContextTags are the wrappers Codex uses to inject harness state as if it
|
||||
// were a user turn. They are the same category as its "developer" messages —
|
||||
// the harness talking to itself — and are excluded from titles and from the
|
||||
// imported conversation.
|
||||
//
|
||||
// An explicit list rather than "any message starting with '<'": a user pasting
|
||||
// XML is saying something real, and guessing would silently drop their words.
|
||||
var codexContextTags = []string{"<environment_context>"}
|
||||
|
||||
func isCodexContextInjection(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
for _, tag := range codexContextTags {
|
||||
if strings.HasPrefix(trimmed, tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type codexBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// codexUUID matches the session id Codex appends to every rollout filename.
|
||||
var codexUUID = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// codexID is the session id, taken from the tail of the filename.
|
||||
//
|
||||
// The full base name — rollout-2026-07-18T11-39-15-019f73d7-... — is not
|
||||
// something anyone wants to retype, and its trailing uuid is exactly the
|
||||
// session_id recorded inside the file. Falling back to the whole base name keeps
|
||||
// a renamed or reshaped file addressable rather than invisible.
|
||||
func codexID(path string) string {
|
||||
base := transcriptID(path)
|
||||
if found := codexUUID.FindString(base); found != "" {
|
||||
return found
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func indexCodexTranscript(agent string, root string, path string) (ForeignSession, bool) {
|
||||
session := ForeignSession{Agent: agent, ID: codexID(path), Path: path}
|
||||
firstPrompt := ""
|
||||
|
||||
_, snapshot, err := scanHeadSnapshot(root, path, defaultHeadLimit, func(line []byte, _ bool) bool {
|
||||
var record codexRecord
|
||||
if json.Unmarshal(line, &record) != nil {
|
||||
return true
|
||||
}
|
||||
if session.Cwd == "" {
|
||||
session.Cwd = record.Payload.Cwd
|
||||
}
|
||||
if session.ModelID == "" {
|
||||
session.ModelID = record.Payload.Model
|
||||
}
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = parseTimestamp(record.Timestamp)
|
||||
}
|
||||
// The first thing the USER said, not the first message: Codex opens every
|
||||
// session with several large "developer" messages carrying its own system
|
||||
// instructions, and titling a session with those would label them all the
|
||||
// same.
|
||||
if firstPrompt == "" && record.Payload.Type == "message" && strings.EqualFold(record.Payload.Role, "user") {
|
||||
if text := codexBlocksText(record.Payload.Content); !isCodexContextInjection(text) {
|
||||
firstPrompt = text
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return ForeignSession{}, false
|
||||
}
|
||||
|
||||
session.Title = summarizeTitle(firstPrompt)
|
||||
if strings.TrimSpace(session.Cwd) == "" {
|
||||
return ForeignSession{}, false
|
||||
}
|
||||
session.source = snapshot
|
||||
session.UpdatedAt = snapshot.modTime
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = session.UpdatedAt
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func translateCodex(file readSeekStater, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
return translateCodexWithByteLimit(file, options, importByteLimit)
|
||||
}
|
||||
|
||||
// translateCodexWithByteLimit keeps the production cap fixed while allowing a
|
||||
// regression test to exercise tail truncation without allocating 32 MiB.
|
||||
func translateCodexWithByteLimit(file readSeekStater, options ReadOptions, maxBytes int) ([]sessions.AppendEventInput, error) {
|
||||
events := newEventTail(effectiveMaxEvents(options.MaxEvents))
|
||||
toolNames := map[string]string{}
|
||||
identities := &importCallIdentities{}
|
||||
activity := newActivityLog(options.Cwd)
|
||||
|
||||
omitted := 0
|
||||
prefixOmitted, err := streamTailLines(file, importLineLimit, maxBytes, func(line []byte, truncated bool) bool {
|
||||
// A RECORD TOO LONG EVEN FOR THE IMPORT CAP IS REPORTED, NOT DROPPED.
|
||||
// Skipping it silently produced a transcript that looked complete: a
|
||||
// question, no answer, then the follow-up. The marker is the honest
|
||||
// answer — the bytes are gone either way, but the reader can see it.
|
||||
if truncated {
|
||||
omitted++
|
||||
return true
|
||||
}
|
||||
var record codexRecord
|
||||
if json.Unmarshal(line, &record) != nil || record.Type != "response_item" {
|
||||
return true
|
||||
}
|
||||
payload := record.Payload
|
||||
|
||||
switch payload.Type {
|
||||
case "message":
|
||||
// "developer" messages are Codex's own system prompt — permissions
|
||||
// boilerplate, collaboration-mode text, tool instructions. They are
|
||||
// the harness talking to itself, not the work, and importing them
|
||||
// would bury the conversation in another product's prompt.
|
||||
role := strings.ToLower(strings.TrimSpace(payload.Role))
|
||||
if role != "user" && role != "assistant" {
|
||||
return true
|
||||
}
|
||||
text := codexBlocksText(payload.Content)
|
||||
if strings.TrimSpace(text) == "" || isCodexContextInjection(text) {
|
||||
return true
|
||||
}
|
||||
events.add(messageEvent(role, text))
|
||||
case "reasoning":
|
||||
if options.IncludeReasoning {
|
||||
var summary []codexBlock
|
||||
if json.Unmarshal(payload.Summary, &summary) == nil {
|
||||
if text := codexBlocksText(summary); strings.TrimSpace(text) != "" {
|
||||
events.add(messageEvent("reasoning", text))
|
||||
}
|
||||
}
|
||||
}
|
||||
case "function_call", "custom_tool_call":
|
||||
// The two call shapes differ only in where the arguments live.
|
||||
toolNames[payload.CallID] = payload.Name
|
||||
arguments := firstNonBlank(payload.Arguments, payload.Input)
|
||||
activity.observeCall(payload.CallID, payload.Name, arguments)
|
||||
events.add(toolCallEvent(identities, payload.Name, payload.CallID, arguments))
|
||||
case "function_call_output", "custom_tool_call_output":
|
||||
name := toolNames[payload.CallID]
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
// Codex records no structured success flag on an output. Preserve that
|
||||
// uncertainty instead of turning arbitrary output text into either a
|
||||
// success claim or a brittle error heuristic.
|
||||
activity.observeResult(payload.CallID, name, tools.StatusUnknown, "")
|
||||
events.add(toolResultEvent(identities, name, payload.CallID, tools.StatusUnknown, codexOutputText(payload.Output)))
|
||||
delete(toolNames, payload.CallID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SAID OUT LOUD. A resumed conversation that quietly lost a record reads as
|
||||
// complete to both the user and the model continuing it — the failure this
|
||||
// makes visible is a question with no answer followed by a follow-up.
|
||||
contextEvents := activity.summaryEvents()
|
||||
if prefixOmitted {
|
||||
contextEvents = append(contextEvents, omittedPrefixEvent())
|
||||
}
|
||||
if omitted > 0 {
|
||||
contextEvents = append(contextEvents, omittedRecordsEvent(omitted))
|
||||
}
|
||||
return capTranslatedEventsDropped(events.values(), contextEvents, effectiveMaxEvents(options.MaxEvents), events.dropped), nil
|
||||
}
|
||||
|
||||
// codexBlocksText flattens input_text/output_text blocks to plain text.
|
||||
func codexBlocksText(blocks []codexBlock) string {
|
||||
parts := []string{}
|
||||
for _, block := range blocks {
|
||||
if strings.TrimSpace(block.Text) != "" {
|
||||
parts = append(parts, block.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// codexOutputText renders a tool output, which is a bare string on some record
|
||||
// kinds and an array of blocks on others.
|
||||
func codexOutputText(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
// Codex frequently stores the array as an ESCAPED STRING rather than as
|
||||
// JSON, so a decoded string may itself be a blocks array.
|
||||
var nested []codexBlock
|
||||
if json.Unmarshal([]byte(text), &nested) == nil {
|
||||
if flattened := codexBlocksText(nested); flattened != "" {
|
||||
return flattened
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
var blocks []codexBlock
|
||||
if json.Unmarshal(raw, &blocks) == nil {
|
||||
if flattened := codexBlocksText(blocks); flattened != "" {
|
||||
return flattened
|
||||
}
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeCodexStore(t *testing.T, lines ...string) (Env, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
path := filepath.Join(home, ".codex", "sessions", "2026", "08", "01",
|
||||
"rollout-2026-08-01T10-00-00-019f73d7-e215-7ce0-ab38-d9e6db354717.jsonl")
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
return testEnv(home, nil), path
|
||||
}
|
||||
|
||||
// TestATurnContextDoesNotBreakOnAFieldItSharesByNameOnly is a regression test
|
||||
// for a defect the real corpus exposed.
|
||||
//
|
||||
// "summary" is an array of blocks on a reasoning item and the bare string "auto"
|
||||
// on turn_context. Typing it as []codexBlock made encoding/json reject the whole
|
||||
// turn_context record, so the model id vanished from every Codex session — with
|
||||
// no error at any layer, because a record that fails to decode is simply skipped.
|
||||
//
|
||||
// Nothing about the model id itself is special here; the point is that ONE
|
||||
// mistyped field in a union struct silently discards every other field on that
|
||||
// record.
|
||||
func TestATurnContextDoesNotBreakOnAFieldItSharesByNameOnly(t *testing.T) {
|
||||
env, _ := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"019f73d7-e215-7ce0-ab38-d9e6db354717","cwd":"/Users/someone/proj"}}`,
|
||||
`{"type":"turn_context","payload":{"model":"gpt-5.6-sol","effort":"high","summary":"auto"}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"fix the parser"}]}}`,
|
||||
)
|
||||
|
||||
found, err := Codex(env).Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("got %d sessions, want 1", len(found))
|
||||
}
|
||||
if found[0].ModelID != "gpt-5.6-sol" {
|
||||
t.Errorf("ModelID = %q, want gpt-5.6-sol — a string-valued \"summary\" on "+
|
||||
"turn_context must not discard the rest of the record", found[0].ModelID)
|
||||
}
|
||||
if found[0].Cwd != "/Users/someone/proj" {
|
||||
t.Errorf("Cwd = %q", found[0].Cwd)
|
||||
}
|
||||
if found[0].ID != "019f73d7-e215-7ce0-ab38-d9e6db354717" {
|
||||
t.Errorf("ID = %q, want the uuid from the filename tail", found[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexHarnessChatterIsNotTheConversation covers the two ways Codex speaks
|
||||
// to itself through channels that look like conversation: "developer" messages
|
||||
// carrying its system prompt, and <environment_context> injected as a user turn.
|
||||
// Neither is the user's work, and both would otherwise dominate a title and the
|
||||
// imported transcript.
|
||||
func TestCodexHarnessChatterIsNotTheConversation(t *testing.T) {
|
||||
env, path := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"s","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"You are Codex. Follow the permissions policy."}]}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/w</cwd>\n</environment_context>"}]}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"install hermes on this pc"}]}}`,
|
||||
)
|
||||
|
||||
found, _ := Codex(env).Discover("")
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("got %d sessions, want 1", len(found))
|
||||
}
|
||||
if found[0].Title != "install hermes on this pc" {
|
||||
t.Errorf("Title = %q, want the first real human turn", found[0].Title)
|
||||
}
|
||||
|
||||
events, err := translateCodexAt("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want only the human turn: %+v", len(events), events)
|
||||
}
|
||||
encoded, _ := json.Marshal(events)
|
||||
for _, unwanted := range []string{"You are Codex", "environment_context"} {
|
||||
if strings.Contains(string(encoded), unwanted) {
|
||||
t.Errorf("harness chatter %q was imported as conversation", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexToolCallsPairUpAcrossBothCallShapes(t *testing.T) {
|
||||
_, path := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"s","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","name":"wait","call_id":"call_1","arguments":"{\"ms\":10}"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"done"}}`,
|
||||
`{"type":"response_item","payload":{"type":"custom_tool_call","name":"exec","call_id":"call_2","input":"ls -la"}}`,
|
||||
`{"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"[{\"type\":\"input_text\",\"text\":\"a.go\"}]"}}`,
|
||||
)
|
||||
all, err := translateCodexAt("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := conversationEvents(all)
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("got %d events, want 4", len(events))
|
||||
}
|
||||
// Both call shapes must carry their name through to their result.
|
||||
for _, pair := range [][2]int{{0, 1}, {2, 3}} {
|
||||
call, result := events[pair[0]], events[pair[1]]
|
||||
if str(t, call, "toolCallId") != str(t, result, "toolCallId") {
|
||||
t.Errorf("call/result ids differ: %q vs %q", str(t, call, "toolCallId"), str(t, result, "toolCallId"))
|
||||
}
|
||||
if str(t, call, "name") != str(t, result, "name") {
|
||||
t.Errorf("result name %q does not match its call %q", str(t, result, "name"), str(t, call, "name"))
|
||||
}
|
||||
if status := str(t, result, "status"); status != "unknown" {
|
||||
t.Errorf("Codex result status = %q, want unknown without structured outcome evidence", status)
|
||||
}
|
||||
}
|
||||
// An output stored as an escaped JSON array must render as its text, not as
|
||||
// raw JSON the reader has to decode by eye.
|
||||
if got := str(t, events[3], "output"); got != "a.go" {
|
||||
t.Errorf("escaped-array output = %q, want the flattened text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexUnknownToolOutcomePreservesOutputWithoutClaimingAFileChange(t *testing.T) {
|
||||
_, path := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"s","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","name":"write_file","call_id":"call_1","arguments":"{\"path\":\"/w/config.go\"}"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"permission denied"}}`,
|
||||
)
|
||||
events, err := translateCodexAt("", path, ReadOptions{Cwd: "/w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation := conversationEvents(events)
|
||||
if len(conversation) != 2 {
|
||||
t.Fatalf("conversation events = %d, want call/result: %+v", len(conversation), conversation)
|
||||
}
|
||||
result := conversation[1]
|
||||
if got := str(t, result, "status"); got != "unknown" {
|
||||
t.Fatalf("result status = %q, want unknown", got)
|
||||
}
|
||||
if got := str(t, result, "output"); got != "permission denied" {
|
||||
t.Fatalf("raw result output = %q", got)
|
||||
}
|
||||
for _, summary := range summaryTexts(t, events) {
|
||||
if strings.Contains(summary, "Files changed") || strings.Contains(summary, "config.go") {
|
||||
t.Fatalf("unverified Codex output created a factual file claim: %q", summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexByteTailDropsOrphanResultButKeepsLaterPairAndDisclosure(t *testing.T) {
|
||||
const testByteLimit = 4 << 10
|
||||
padding := strings.Repeat("x", testByteLimit)
|
||||
_, path := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"s","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","name":"old_call","call_id":"old","arguments":"{}"}}`,
|
||||
`{"type":"padding","payload":"`+padding+`"}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call_output","call_id":"old","output":"orphaned output"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","name":"new_call","call_id":"new","arguments":"{}"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call_output","call_id":"new","output":"kept output"}}`,
|
||||
)
|
||||
events, err := translateCodexAtWithByteLimit("", path, ReadOptions{}, testByteLimit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation := conversationEvents(events)
|
||||
if len(conversation) != 2 {
|
||||
t.Fatalf("byte-tail conversation = %d events, want only later pair: %+v", len(conversation), conversation)
|
||||
}
|
||||
if str(t, conversation[0], "name") != "new_call" ||
|
||||
str(t, conversation[1], "name") != "new_call" ||
|
||||
str(t, conversation[0], "toolCallId") != str(t, conversation[1], "toolCallId") {
|
||||
t.Fatalf("later valid pair was not retained: %+v", conversation)
|
||||
}
|
||||
for _, event := range conversation {
|
||||
if str(t, event, "name") == "old_call" || str(t, event, "output") == "orphaned output" {
|
||||
t.Fatalf("orphan result survived byte-tail normalization: %+v", event)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(summaryTexts(t, events), "\n")
|
||||
if !strings.Contains(joined, "Older transcript records were not imported") {
|
||||
t.Fatalf("byte-tail loss was not disclosed: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCapCannotLetActivitySummaryEvictSourceTail(t *testing.T) {
|
||||
_, path := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"s","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","name":"read_file","call_id":"call_1","arguments":"{\"path\":\"parser.go\"}"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"package parser"}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"final codex answer"}]}}`,
|
||||
)
|
||||
events, err := translateCodexAt("", path, ReadOptions{MaxEvents: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d capped events, want 2", len(events))
|
||||
}
|
||||
if note := str(t, events[0], "content"); !strings.Contains(note, "not imported") {
|
||||
t.Fatalf("source truncation was silent: got %q", note)
|
||||
}
|
||||
if got := str(t, events[1], "content"); got != "final codex answer" {
|
||||
t.Fatalf("source tail was evicted by generated context: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDiscoveryIsFixedDepth(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
root := filepath.Join(home, ".codex", "sessions")
|
||||
good := filepath.Join(root, "2026", "08", "01", "rollout-a-019f73d7-e215-7ce0-ab38-d9e6db354717.jsonl")
|
||||
writeFile(t, good, `{"type":"session_meta","payload":{"session_id":"s","cwd":"/w"}}`+"\n")
|
||||
|
||||
// A live OPENAI_API_KEY lives at ~/.codex/auth.json, one level above the
|
||||
// sessions root, plus decoy transcripts a walk would reach.
|
||||
writeFile(t, filepath.Join(home, ".codex", "auth.json"), `{"OPENAI_API_KEY":"sk-MUST_NEVER_BE_READ"}`)
|
||||
for _, stray := range []string{
|
||||
filepath.Join(root, "stray.jsonl"),
|
||||
filepath.Join(root, "2026", "deep.jsonl"),
|
||||
filepath.Join(root, "2026", "08", "01", "nested", "deeper.jsonl"),
|
||||
filepath.Join(home, ".codex", "leaked.jsonl"),
|
||||
} {
|
||||
writeFile(t, stray, `{"type":"session_meta","payload":{"session_id":"x","cwd":"/w"}}`+"\n")
|
||||
}
|
||||
|
||||
paths := Codex(testEnv(home, nil)).(codex).transcripts()
|
||||
if len(paths) != 1 || paths[0] != good {
|
||||
t.Fatalf("transcripts = %v, want exactly [%s]", paths, good)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealCodexCorpusStillParses(t *testing.T) {
|
||||
if os.Getenv("ZERO_TEST_LIVE_AGENT_SESSIONS") != "1" {
|
||||
t.Skip("set ZERO_TEST_LIVE_AGENT_SESSIONS=1 to inspect the local Codex store")
|
||||
}
|
||||
env := OSEnv()
|
||||
root := codexRoot(env)
|
||||
if root == "" {
|
||||
t.Skip("no home directory")
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skip("no Codex store on this machine")
|
||||
}
|
||||
adapter := Codex(env)
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total := len(adapter.(codex).transcripts())
|
||||
if total == 0 {
|
||||
t.Skip("store exists but holds no rollouts")
|
||||
}
|
||||
titled, modelled := 0, 0
|
||||
for _, session := range found {
|
||||
if session.Title != "" && session.Title != "untitled" && !isCodexContextInjection(session.Title) {
|
||||
titled++
|
||||
}
|
||||
if session.ModelID != "" {
|
||||
modelled++
|
||||
}
|
||||
}
|
||||
t.Logf("indexed %d of %d rollouts; %d titled, %d with a model", len(found), total, titled, modelled)
|
||||
if len(found) == 0 {
|
||||
t.Fatal("no Codex sessions indexed from a non-empty store")
|
||||
}
|
||||
// REPORTED, NOT ASSERTED. What these counts measure is the shape of the store
|
||||
// on the machine running them, which is not a property of this package: the
|
||||
// same code reported "44 of 44 rollouts, 43 with a model" on one machine and
|
||||
// "2 of 2, 0 with a model" on a reviewer's. Failing on the second is a red
|
||||
// build for the contributor and a green one for CI, which is backwards — CI
|
||||
// has no store and skips, so the assertion never ran where a regression would
|
||||
// actually land.
|
||||
//
|
||||
// The shapes behind both numbers are pinned by construction in
|
||||
// fixture_corpus_test.go, which runs everywhere and cannot skip. What this
|
||||
// test is still good for is noticing a shape the fixtures do not cover, so it
|
||||
// prints its counts and leaves the judgement to the reader.
|
||||
if titled == 0 {
|
||||
t.Logf("no session in the live store got a real title; if that is unexpected here, "+
|
||||
"the context-injection filter may have drifted (%d rollouts)", total)
|
||||
}
|
||||
if modelled == 0 {
|
||||
t.Logf("no session in the live store carries a model: every rollout here has its "+
|
||||
"turn_context outside the head budget. TestARolloutWithALateTurnContextIndexesWithoutAModel "+
|
||||
"pins that shape deterministically (%d rollouts)", total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agentsessions
|
||||
|
||||
import "github.com/Gitlawb/zero/internal/sessions"
|
||||
|
||||
// The production readers take an open handle (see openSelectedSource) so the
|
||||
// identity that was verified is the one that is read. Tests that only care
|
||||
// about translation still build a file and name it; these wrappers open it the
|
||||
// same way production does and hand the handle through.
|
||||
func translateFamily1At(root string, path string, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
file, err := openContained(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
return translateFamily1(file, options)
|
||||
}
|
||||
|
||||
func translateCodexAt(root string, path string, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
return translateCodexAtWithByteLimit(root, path, options, importByteLimit)
|
||||
}
|
||||
|
||||
func translateCodexAtWithByteLimit(root string, path string, options ReadOptions, maxBytes int) ([]sessions.AppendEventInput, error) {
|
||||
file, err := openContained(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
return translateCodexWithByteLimit(file, options, maxBytes)
|
||||
}
|
||||
|
||||
func streamTailLinesAt(root string, path string, maxLineBytes int, maxBytes int, visit func(line []byte, truncated bool) bool) (bool, error) {
|
||||
file, err := openContained(root, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer file.Close()
|
||||
return streamTailLines(file, maxLineBytes, maxBytes, visit)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// family1 is the layout two of the agents surveyed independently arrived at:
|
||||
// one JSONL file per session, in a directory named after the working directory,
|
||||
// with records carrying {type, cwd, timestamp, message:{role, content}} and
|
||||
// content blocks of text / thinking / tool_use / tool_result.
|
||||
//
|
||||
// Claude Code ~/.claude/projects/<slug>/<uuid>.jsonl
|
||||
// Factory Droid ~/.factory/sessions/<slug>/<uuid>.jsonl
|
||||
//
|
||||
// They differ only in where the store lives and which record carries the title:
|
||||
// Claude Code writes an "ai-title" record, Factory puts a "title" on
|
||||
// "session_start". Everything else — the block vocabulary, the tool_use/
|
||||
// tool_result pairing, the role names — is byte-for-byte the same shape, which
|
||||
// is why one parser serves both. Pi shares the DIRECTORY layout and reuses
|
||||
// discoverFamily1 for it, but its message schema is its own: see pi.go.
|
||||
//
|
||||
// An adapter is therefore a name and a root.
|
||||
type family1 struct {
|
||||
name string
|
||||
root string
|
||||
}
|
||||
|
||||
func (adapter family1) Name() string { return adapter.name }
|
||||
|
||||
func (adapter family1) Discover(cwd string) ([]ForeignSession, error) {
|
||||
return discoverFamily1(adapter.name, adapter.root, cwd, indexFamily1Transcript)
|
||||
}
|
||||
|
||||
// Read translates one session into Zero events.
|
||||
//
|
||||
// Unlike Discover, this reports its errors: the user has named a specific
|
||||
// session, and returning an empty conversation because the file moved would be
|
||||
// a lie about what that session contained.
|
||||
func (adapter family1) Read(source ForeignSession, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
if source.Agent != adapter.name || source.ID != transcriptID(source.Path) {
|
||||
return nil, errNotThisAgent(adapter.name)
|
||||
}
|
||||
// One handle from identity check to final check: see openSelectedSource.
|
||||
file, err := openSelectedSource(adapter.root, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
events, err := translateFamily1(file, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSourceHandle(file, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ClaudeCode reads Claude Code's transcripts.
|
||||
func ClaudeCode(env Env) Adapter {
|
||||
return family1{name: "claude-code", root: claudeCodeRoot(env)}
|
||||
}
|
||||
|
||||
// FactoryDroid reads Factory's droid transcripts.
|
||||
func FactoryDroid(env Env) Adapter {
|
||||
return family1{name: "factory", root: factoryRoot(env)}
|
||||
}
|
||||
|
||||
// family1Record is the subset of a family-1 transcript record this package
|
||||
// reads. Unknown fields are ignored by encoding/json, which is what lets the
|
||||
// adapter survive the format gaining records it has never seen.
|
||||
type family1Record struct {
|
||||
Type string `json:"type"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"gitBranch"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
UUID string `json:"uuid"`
|
||||
AITitle string `json:"aiTitle"`
|
||||
// Title is Factory's spelling of the same idea, carried on its
|
||||
// session_start record rather than on a record of its own.
|
||||
Title string `json:"title"`
|
||||
Message *family1Message `json:"message"`
|
||||
}
|
||||
|
||||
type family1Message struct {
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// family1Block is one content block. Message content is either a bare string or
|
||||
// an array of these.
|
||||
type family1Block struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
// discoverFamily1 is the shared body for the slugged-directory JSONL agents.
|
||||
//
|
||||
// The slug is a hint only. When a candidate directory exists it is searched
|
||||
// first, which turns 1,266 files into the ~223 that belong to this workspace;
|
||||
// when none matches, every project directory is indexed and the cwd recorded
|
||||
// INSIDE each transcript decides. That ordering matters because the slug cannot
|
||||
// be reversed — see slugCandidates.
|
||||
func discoverFamily1(
|
||||
agent string,
|
||||
root string,
|
||||
cwd string,
|
||||
index func(agent string, root string, path string) (ForeignSession, bool),
|
||||
) ([]ForeignSession, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Discovery and the later selected-source open both rely on the directories
|
||||
// returned by globSessionDirs, which Lstat-skips a symlinked project directory
|
||||
// (following one could descend into a credential tree). The slug fast path
|
||||
// must narrow WITHIN that same set, not join root/slug on its own.
|
||||
sessionDirs := globSessionDirs(root)
|
||||
dirs := []string{}
|
||||
if strings.TrimSpace(cwd) != "" {
|
||||
wanted := map[string]bool{}
|
||||
for _, slug := range slugCandidates(cwd) {
|
||||
wanted[slug] = true
|
||||
}
|
||||
for _, dir := range sessionDirs {
|
||||
if wanted[filepath.Base(dir)] &&
|
||||
len(globTranscripts(root, filepath.Join(dir, "*"+transcriptExt))) > 0 {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dirs) == 0 {
|
||||
dirs = sessionDirs
|
||||
}
|
||||
|
||||
found := []ForeignSession{}
|
||||
for _, dir := range dirs {
|
||||
for _, path := range globTranscripts(root, filepath.Join(dir, "*"+transcriptExt)) {
|
||||
session, ok := index(agent, root, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// The transcript's own cwd is the authority, never the directory name.
|
||||
if strings.TrimSpace(cwd) != "" && !sameDir(cwd, session.Cwd) {
|
||||
continue
|
||||
}
|
||||
found = append(found, session)
|
||||
}
|
||||
}
|
||||
sortByRecency(found)
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// indexFamily1Transcript builds an index entry from a bounded read of the file's
|
||||
// head. It returns false for anything it cannot identify as a session, which is
|
||||
// how a partially written, empty, or reshaped file drops out of discovery
|
||||
// instead of failing it.
|
||||
func indexFamily1Transcript(agent string, root string, path string) (ForeignSession, bool) {
|
||||
session := ForeignSession{
|
||||
Agent: agent,
|
||||
ID: transcriptID(path),
|
||||
Path: path,
|
||||
}
|
||||
firstPrompt := ""
|
||||
|
||||
_, snapshot, err := scanHeadSnapshot(root, path, defaultHeadLimit, func(line []byte, truncated bool) bool {
|
||||
var record family1Record
|
||||
if json.Unmarshal(line, &record) != nil {
|
||||
// A RECORD TOO LONG TO PARSE STILL CARRIES ITS METADATA AT THE FRONT.
|
||||
// Skipping it whole is right for the body — the cap exists so a giant
|
||||
// tool result is not held in memory — but cwd, the git branch and the
|
||||
// timestamp sit before it, and dropping them with the body cost the
|
||||
// entire session: with no workspace to bind to, discovery dropped a
|
||||
// transcript the user can see on disk, and it looked exactly like a
|
||||
// legitimate stub.
|
||||
//
|
||||
// Only for a TRUNCATED line. A genuinely malformed one is skipped as
|
||||
// before: transcripts are appended live and the last line is routinely
|
||||
// half-written, and guessing at fields there would invent them.
|
||||
if truncated {
|
||||
recovered := topLevelStrings(line, "cwd", "gitBranch", "timestamp")
|
||||
if session.Cwd == "" {
|
||||
session.Cwd = recovered["cwd"]
|
||||
}
|
||||
if session.GitBranch == "" {
|
||||
session.GitBranch = recovered["gitBranch"]
|
||||
}
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = parseTimestamp(recovered["timestamp"])
|
||||
}
|
||||
}
|
||||
// One malformed line is not a malformed file: transcripts are
|
||||
// appended live and the last line is routinely half-written.
|
||||
return true
|
||||
}
|
||||
if session.Cwd == "" {
|
||||
session.Cwd = record.Cwd
|
||||
}
|
||||
if session.GitBranch == "" {
|
||||
session.GitBranch = record.GitBranch
|
||||
}
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = parseTimestamp(record.Timestamp)
|
||||
}
|
||||
if record.Message != nil && session.ModelID == "" {
|
||||
session.ModelID = record.Message.Model
|
||||
}
|
||||
// An agent-supplied title beats a truncated first prompt, so it wins when
|
||||
// present: Claude Code writes one as an "ai-title" record, Factory as a
|
||||
// "title" on "session_start". Pi writes none, so the prompt is used.
|
||||
if title := firstNonBlank(record.AITitle, record.Title); title != "" {
|
||||
session.Title = title
|
||||
}
|
||||
if firstPrompt == "" && record.Type == "user" && record.Message != nil {
|
||||
firstPrompt = family1Text(record.Message.Content)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return ForeignSession{}, false
|
||||
}
|
||||
|
||||
if session.Title == "" {
|
||||
session.Title = summarizeTitle(firstPrompt)
|
||||
}
|
||||
// A file with no cwd is not a session transcript — most likely a sidecar the
|
||||
// agent dropped into the same directory. Refusing it here keeps discovery
|
||||
// from listing entries that Read could never make sense of.
|
||||
if strings.TrimSpace(session.Cwd) == "" {
|
||||
return ForeignSession{}, false
|
||||
}
|
||||
session.source = snapshot
|
||||
session.UpdatedAt = snapshot.modTime
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = session.UpdatedAt
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
// transcriptID is the session's identifier: the file's base name without its
|
||||
// extension.
|
||||
//
|
||||
// Deliberately NOT stored as a path. Read resolves an id by globbing and
|
||||
// comparing base names, so a hostile or mistyped id such as "../../auth" can
|
||||
// never be joined onto a root and opened — it simply matches nothing.
|
||||
func transcriptID(path string) string {
|
||||
base := filepath.Base(path)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
|
||||
// family1Text flattens a message's content to plain text. Content is either a
|
||||
// bare string or an array of typed blocks; tool calls and thinking contribute
|
||||
// nothing here, since this feeds titles and previews.
|
||||
func family1Text(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
return text
|
||||
}
|
||||
var blocks []family1Block
|
||||
if json.Unmarshal(raw, &blocks) != nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
for _, block := range blocks {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
parts = append(parts, block.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func sortByRecency(items []ForeignSession) {
|
||||
sort.SliceStable(items, func(left, right int) bool {
|
||||
if !items[left].UpdatedAt.Equal(items[right].UpdatedAt) {
|
||||
return items[left].UpdatedAt.After(items[right].UpdatedAt)
|
||||
}
|
||||
return items[left].ID < items[right].ID
|
||||
})
|
||||
}
|
||||
|
||||
func parseTimestamp(value string) time.Time {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if parsed, err := time.Parse(layout, trimmed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// summarizeTitle collapses a prompt to a single short line. Runes, not bytes,
|
||||
// so a multi-byte character is never split into invalid UTF-8.
|
||||
func summarizeTitle(prompt string) string {
|
||||
// Sanitize the complete source before shortening it. Cutting first can remove
|
||||
// the suffix or closing quote that makes a credential recognizable and leave
|
||||
// a secret-bearing fragment in the persisted title.
|
||||
collapsed := strings.Join(strings.Fields(DisplayField(prompt)), " ")
|
||||
if collapsed == "" {
|
||||
return "untitled"
|
||||
}
|
||||
const limit = 72
|
||||
runes := []rune(collapsed)
|
||||
if len(runes) <= limit {
|
||||
return collapsed
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:limit])) + "…"
|
||||
}
|
||||
|
||||
func errNotThisAgent(name string) error {
|
||||
return errors.New("agentsessions: selected session does not belong to " + name)
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// The fixtures below are hand-written to the shapes observed in a real
|
||||
// ~/.claude/projects corpus (record types, field names, nesting) rather than
|
||||
// copied from real transcripts, which carry the author's actual work. The
|
||||
// real-corpus test at the bottom of this file keeps the two honest: it runs
|
||||
// against the live store when one exists and skips when it does not.
|
||||
|
||||
// writeClaudeStore lays out a projects/ tree and returns its root.
|
||||
func writeClaudeStore(t *testing.T, files map[string][]string) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "projects")
|
||||
for relative, lines := range files {
|
||||
writeFile(t, filepath.Join(root, relative), strings.Join(lines, "\n")+"\n")
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestDiscoverIndexesASessionFromABoundedHeadRead(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/aaa.jsonl": {
|
||||
`{"type":"mode","mode":"default"}`,
|
||||
`{"type":"user","cwd":"/Users/someone/proj","gitBranch":"main","sessionId":"aaa","timestamp":"2026-08-01T10:00:00.000Z","message":{"role":"user","content":"Fix the flaky retry test"}}`,
|
||||
`{"type":"ai-title","aiTitle":"Fix flaky retry test","sessionId":"aaa"}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"On it."}]}}`,
|
||||
},
|
||||
})
|
||||
|
||||
got, err := discoverFamily1("claude-code", root, "/Users/someone/proj", indexFamily1Transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d sessions, want 1", len(got))
|
||||
}
|
||||
session := got[0]
|
||||
if session.ID != "aaa" {
|
||||
t.Errorf("ID = %q, want aaa", session.ID)
|
||||
}
|
||||
if session.Cwd != "/Users/someone/proj" {
|
||||
t.Errorf("Cwd = %q", session.Cwd)
|
||||
}
|
||||
if session.GitBranch != "main" {
|
||||
t.Errorf("GitBranch = %q, want main", session.GitBranch)
|
||||
}
|
||||
if session.ModelID != "claude-opus-5" {
|
||||
t.Errorf("ModelID = %q, want claude-opus-5", session.ModelID)
|
||||
}
|
||||
// The agent's own generated title beats a truncated first prompt.
|
||||
if session.Title != "Fix flaky retry test" {
|
||||
t.Errorf("Title = %q, want the ai-title record to win", session.Title)
|
||||
}
|
||||
if session.StartedAt.IsZero() {
|
||||
t.Error("StartedAt was not populated from the first timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFirstPromptIsTheTitleWhenTheAgentRecordedNone(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/bbb.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"bbb","message":{"role":"user","content":" Investigate the crash \n in the parser "}}`,
|
||||
},
|
||||
})
|
||||
got, _ := discoverFamily1("claude-code", root, "", indexFamily1Transcript)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d sessions, want 1", len(got))
|
||||
}
|
||||
if got[0].Title != "Investigate the crash in the parser" {
|
||||
t.Errorf("Title = %q, want the whitespace-collapsed prompt", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheRecordedCwdBeatsTheDirectoryName is the property that makes the lossy
|
||||
// slug safe. Both directories below are plausible spellings of the requested
|
||||
// cwd, but only one transcript actually ran there.
|
||||
func TestTheRecordedCwdBeatsTheDirectoryName(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
// Same slug shape; different real directories.
|
||||
"-Users-someone-dev-zero/real.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/dev/zero","sessionId":"real","message":{"role":"user","content":"in the right place"}}`,
|
||||
},
|
||||
"-Users-someone-dev-zero2/impostor.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/dev-zero","sessionId":"impostor","message":{"role":"user","content":"a different directory"}}`,
|
||||
},
|
||||
})
|
||||
|
||||
got, err := discoverFamily1("claude-code", root, "/Users/someone/dev/zero", indexFamily1Transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "real" {
|
||||
t.Fatalf("got %v, want only the transcript whose recorded cwd matches", ids(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubagentTranscriptsAreNotSessions pins the fixed-depth glob against the
|
||||
// real corpus shape: 997 of 1,266 files in the live store are sub-agent
|
||||
// transcripts under <session>/subagents/, and listing them as resumable
|
||||
// sessions would bury the 269 real ones.
|
||||
func TestSubagentTranscriptsAreNotSessions(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/parent.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"parent","message":{"role":"user","content":"top level"}}`,
|
||||
},
|
||||
"-Users-someone-proj/parent/subagents/agent-1.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"agent-1","message":{"role":"user","content":"delegated"}}`,
|
||||
},
|
||||
})
|
||||
got, _ := discoverFamily1("claude-code", root, "/Users/someone/proj", indexFamily1Transcript)
|
||||
if len(got) != 1 || got[0].ID != "parent" {
|
||||
t.Fatalf("got %v, want only the top-level session", ids(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonTranscriptsAreSkippedRatherThanListed covers what the live corpus
|
||||
// actually contains alongside real sessions: single-record "bridge-session"
|
||||
// stubs (9 of 269 there), empty files, and half-written trailing lines from a
|
||||
// session being appended to right now.
|
||||
func TestNonTranscriptsAreSkippedRatherThanListed(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/good.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"good","message":{"role":"user","content":"real work"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"do`, // torn final line
|
||||
},
|
||||
"-Users-someone-proj/bridge.jsonl": {
|
||||
`{"type":"bridge-session","sessionId":"bridge"}`,
|
||||
},
|
||||
"-Users-someone-proj/empty.jsonl": {""},
|
||||
"-Users-someone-proj/garbage.jsonl": {"not json at all", "{also not"},
|
||||
})
|
||||
|
||||
got, err := discoverFamily1("claude-code", root, "", indexFamily1Transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("discovery must not fail on junk beside real sessions: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "good" {
|
||||
t.Fatalf("got %v, want only the real session — a torn trailing line must "+
|
||||
"not discard the records before it", ids(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryOfAnAbsentStoreIsEmptyNotAnError(t *testing.T) {
|
||||
got, err := discoverFamily1("claude-code", filepath.Join(t.TempDir(), "never-created"), "", indexFamily1Transcript)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("got (%v, %v), want (empty, nil)", ids(got), err)
|
||||
}
|
||||
if got, err := discoverFamily1("claude-code", "", "", indexFamily1Transcript); err != nil || len(got) != 0 {
|
||||
t.Fatalf("empty root: got (%v, %v), want (empty, nil)", ids(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAreListedMostRecentFirst(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/old.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"old","message":{"role":"user","content":"first"}}`,
|
||||
},
|
||||
"-Users-someone-proj/new.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"new","message":{"role":"user","content":"second"}}`,
|
||||
},
|
||||
})
|
||||
// Make the ordering unambiguous rather than relying on write order.
|
||||
older := filepath.Join(root, "-Users-someone-proj", "old.jsonl")
|
||||
if err := os.Chtimes(older, mustTime("2026-01-01T00:00:00Z"), mustTime("2026-01-01T00:00:00Z")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := discoverFamily1("claude-code", root, "", indexFamily1Transcript)
|
||||
if len(got) != 2 || got[0].ID != "new" {
|
||||
t.Fatalf("got %v, want the most recently updated session first", ids(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Import resolves ids only from discovery results; it never joins caller text
|
||||
// onto the foreign store root.
|
||||
func TestImportCannotBeTalkedIntoOpeningAnArbitraryPath(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/aaa.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"aaa","message":{"role":"user","content":"x"}}`,
|
||||
},
|
||||
})
|
||||
// A credential file one level above the store, exactly as every surveyed
|
||||
// agent ships one.
|
||||
secret := filepath.Join(filepath.Dir(root), "auth.json")
|
||||
writeFile(t, secret, `{"access_token":"tok_MUST_NEVER_BE_READ"}`)
|
||||
adapter := family1{name: "claude-code", root: root}
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
|
||||
for _, hostile := range []string{
|
||||
"../auth",
|
||||
"../../auth",
|
||||
filepath.Join("..", "auth.json"),
|
||||
secret,
|
||||
strings.TrimSuffix(secret, ".json"),
|
||||
"/etc/passwd",
|
||||
} {
|
||||
if _, err := Import(store, adapter, hostile, ReadOptions{}); err == nil {
|
||||
t.Errorf("Import(%q) succeeded, want an error", hostile)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := Import(store, adapter, "aaa", ReadOptions{}); err != nil {
|
||||
t.Errorf("Import(\"aaa\") = %v, want the discovered transcript", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheRealCorpusStillParses runs against the live store when there is one.
|
||||
// The fixtures above pin the logic; this pins the FORMAT — these are
|
||||
// undocumented files belonging to another product, and a shape change upstream
|
||||
// should surface here rather than as an empty list in front of a user.
|
||||
func TestTheRealCorpusStillParses(t *testing.T) {
|
||||
if os.Getenv("ZERO_TEST_LIVE_AGENT_SESSIONS") != "1" {
|
||||
t.Skip("set ZERO_TEST_LIVE_AGENT_SESSIONS=1 to inspect the local Claude Code store")
|
||||
}
|
||||
env := OSEnv()
|
||||
adapter := ClaudeCode(env)
|
||||
root := claudeCodeRoot(env)
|
||||
if root == "" {
|
||||
t.Skip("no home directory")
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skip("no Claude Code store on this machine")
|
||||
}
|
||||
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
t.Fatalf("discovering the real store failed: %v", err)
|
||||
}
|
||||
transcripts := 0
|
||||
for _, dir := range globSessionDirs(root) {
|
||||
transcripts += len(globTranscripts(root, filepath.Join(dir, "*"+transcriptExt)))
|
||||
}
|
||||
if transcripts == 0 {
|
||||
t.Skip("store exists but holds no transcripts")
|
||||
}
|
||||
|
||||
// Every indexed session must carry the fields the CLI will print. A format
|
||||
// change that silently blanks one of these is the failure mode worth
|
||||
// catching.
|
||||
for _, session := range found {
|
||||
// NAMED, NOT DUMPED. This walks the developer's REAL store, so %+v here
|
||||
// printed their own session titles, working directories and file paths
|
||||
// into the test output — and into any CI log or pasted failure report
|
||||
// that carried it. The field that is empty is the whole diagnostic; the
|
||||
// value that is missing is by definition not the interesting part.
|
||||
var missing []string
|
||||
for name, value := range map[string]string{"ID": session.ID, "Cwd": session.Cwd, "Title": session.Title} {
|
||||
if value == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("a live-store index entry is missing %v — every one of these is a field the CLI prints", missing)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// REPORTED, NOT ASSERTED — for the same reason as the Codex counterpart. This
|
||||
// ratio describes the reviewer's disk, not this package: it read 98% here
|
||||
// (the 7 misses all single-record bridge-session stubs) and 71% on a
|
||||
// reviewer's smaller store, so the threshold decided who got a red build
|
||||
// rather than whether anything was wrong. CI has no store and skips, so the
|
||||
// assertion never ran where a regression would land.
|
||||
//
|
||||
// The two shapes behind a drop are pinned by construction in
|
||||
// fixture_corpus_test.go, which runs everywhere — but they are pinned as two
|
||||
// DIFFERENT things and the distinction matters to whoever reads this next.
|
||||
// "No cwd in any record" is pinned as correct behaviour: such a session has
|
||||
// no workspace to resume into. "A cwd only in a record past MaxLineBytes" is
|
||||
// pinned as an OPEN DEFECT — the session is lost, and the test asserts that
|
||||
// loss rather than its absence. Neither is a claim that the second case is
|
||||
// handled.
|
||||
if ratio := float64(len(found)) / float64(transcripts); ratio < 0.85 {
|
||||
t.Logf("indexed %d of %d real transcripts (%.0f%%) in the live store. A low ratio here is "+
|
||||
"worth looking at by hand: the known-legitimate exclusion is a session with no cwd in "+
|
||||
"any record, and the known defect is a cwd that only appears past defaultHeadLimit.MaxLineBytes",
|
||||
len(found), transcripts, ratio*100)
|
||||
}
|
||||
t.Logf("indexed %d of %d transcripts in the live store", len(found), transcripts)
|
||||
}
|
||||
|
||||
func mustTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func ids(items []ForeignSession) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, item.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestASymlinkedSlugDirectoryIsNotListedThenRefused pins the fix for the
|
||||
// list-then-refuse divergence. The slug fast path used to glob straight through
|
||||
// a symlinked project directory, while selected-source resolution skips it — so
|
||||
// Discover listed a session Import could not resolve. Both must agree; here, by
|
||||
// both declining to follow the symlink.
|
||||
func TestASymlinkedSlugDirectoryIsNotListedThenRefused(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "projects")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The transcript lives outside the store, reachable only through a symlinked
|
||||
// slug directory whose name matches cwd /w.
|
||||
elsewhere := filepath.Join(t.TempDir(), "real")
|
||||
writeFile(t, filepath.Join(elsewhere, "sneaky.jsonl"),
|
||||
`{"type":"user","cwd":"/w","sessionId":"sneaky","message":{"role":"user","content":"hi"}}`+"\n")
|
||||
if err := os.Symlink(elsewhere, filepath.Join(root, "-w")); err != nil {
|
||||
t.Skipf("symlinks unavailable on this platform: %v", err)
|
||||
}
|
||||
|
||||
adapter := family1{name: "claude-code", root: root}
|
||||
found, err := adapter.Discover("/w")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// CONTAINMENT FIRST, agreement second. Checking only that Discover and Read
|
||||
// AGREE passes in two opposite worlds: the one where both correctly refuse a
|
||||
// path reached through a symlink, and the one where both happily follow it
|
||||
// out of the store. Agreement is the weaker half of the property and it was
|
||||
// the only half asserted.
|
||||
for _, session := range found {
|
||||
if session.ID == "sneaky" {
|
||||
t.Errorf("Discover followed a symlink out of the store and listed %q from %s", session.ID, elsewhere)
|
||||
}
|
||||
}
|
||||
if _, err := adapter.Read(ForeignSession{Agent: adapter.Name(), ID: "sneaky", Path: filepath.Join(root, "-w", "sneaky.jsonl")}, ReadOptions{}); err == nil {
|
||||
t.Errorf("Read followed a symlink out of the store and imported %s", elsewhere)
|
||||
}
|
||||
// AND THEN agreement, which is what the original fast path broke: it listed
|
||||
// "sneaky" by globbing through the symlink while Read refused it.
|
||||
for _, session := range found {
|
||||
if _, err := adapter.Read(session, ReadOptions{}); err != nil {
|
||||
t.Errorf("Discover listed %q but Read refuses it: %v — list-then-refuse", session.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE LAST-ACTIVITY STAMP MUST STILL ARRIVE THROUGH THE REAL ROOT. fileModTime
|
||||
// now opens the transcript through os.Root instead of calling os.Stat, so a root
|
||||
// shape it could not open would leave every session on the zero time — sorted
|
||||
// last, showing no age — and nothing in this suite would notice, because
|
||||
// UpdatedAt had no coverage at all. Discovery through the adapter's own root is
|
||||
// the arrangement production uses; the containment test in jsonl_test.go covers
|
||||
// the refusal, this covers the success.
|
||||
func TestDiscoveryStampsASessionWithItsTranscriptModTime(t *testing.T) {
|
||||
root := writeClaudeStore(t, map[string][]string{
|
||||
"-Users-someone-proj/aaa.jsonl": {
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"aaa","message":{"role":"user","content":"go"}}`,
|
||||
},
|
||||
})
|
||||
stamp := time.Date(2026, 5, 4, 3, 2, 1, 0, time.UTC)
|
||||
if err := os.Chtimes(filepath.Join(root, "-Users-someone-proj", "aaa.jsonl"), stamp, stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := discoverFamily1("claude-code", root, "/Users/someone/proj", indexFamily1Transcript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d sessions, want 1", len(got))
|
||||
}
|
||||
if !got[0].UpdatedAt.UTC().Equal(stamp) {
|
||||
t.Errorf("UpdatedAt = %v, want the transcript's mtime %v", got[0].UpdatedAt.UTC(), stamp)
|
||||
}
|
||||
// No record in this fixture carries a timestamp, so StartedAt falls back to
|
||||
// the same stamp rather than staying zero.
|
||||
if !got[0].StartedAt.UTC().Equal(stamp) {
|
||||
t.Errorf("StartedAt = %v, want the mtime fallback %v", got[0].StartedAt.UTC(), stamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// The TestTheReal*CorpusStillParses tests pin the on-disk FORMAT, but only on a
|
||||
// machine that has a live ~/.claude or ~/.codex store — in CI they Skip, so the
|
||||
// format goes unchecked exactly where regressions would land. These tests point
|
||||
// the real adapters at a checked-in fixture store under testdata via the same
|
||||
// redirect variables production honours (CLAUDE_CONFIG_DIR, CODEX_HOME), so the
|
||||
// format-pin runs deterministically and never skips. A shape change upstream
|
||||
// fails a test here instead of silently emptying a picker in front of a user.
|
||||
//
|
||||
// The fixtures carry generic, invented work — never a real transcript — so
|
||||
// nothing personal is checked in; the live-store tests keep them honest against
|
||||
// the real shapes.
|
||||
func fixtureEnv(t *testing.T, redirectVar string, dir string) Env {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs(filepath.Join("testdata", dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testEnv("", map[string]string{redirectVar: abs})
|
||||
}
|
||||
|
||||
func TestTheClaudeCodeFixtureParsesEndToEnd(t *testing.T) {
|
||||
adapter := ClaudeCode(fixtureEnv(t, "CLAUDE_CONFIG_DIR", "claude"))
|
||||
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatal("the checked-in Claude Code fixture indexed no sessions — the format pin is broken")
|
||||
}
|
||||
session := found[0]
|
||||
for name, value := range map[string]string{
|
||||
"ID": session.ID, "Cwd": session.Cwd, "Title": session.Title, "ModelID": session.ModelID,
|
||||
} {
|
||||
if value == "" {
|
||||
t.Errorf("%s is empty in the fixture index — it is a field the CLI prints", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Discover and Read must agree: a session the picker lists must import.
|
||||
events, err := adapter.Read(session, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Read of a discovered fixture session failed: %v", err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("Read produced no events from the fixture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCodexFixtureParsesEndToEnd(t *testing.T) {
|
||||
adapter := Codex(fixtureEnv(t, "CODEX_HOME", "codex"))
|
||||
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatal("the checked-in Codex fixture indexed no sessions — the format pin is broken")
|
||||
}
|
||||
session := found[0]
|
||||
if session.ID == "" || session.Cwd == "" || session.ModelID == "" {
|
||||
t.Errorf("incomplete Codex fixture index entry: %+v", session)
|
||||
}
|
||||
|
||||
events, err := adapter.Read(session, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Read of a discovered Codex fixture session failed: %v", err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("Read produced no events from the Codex fixture")
|
||||
}
|
||||
}
|
||||
|
||||
// WHY THESE FIXTURES EXIST. The TestTheReal*CorpusStillParses tests below assert
|
||||
// STATISTICS over whatever store the machine running them happens to have, and
|
||||
// that is not a property of this package. The same code reported "44 of 44
|
||||
// rollouts, 43 with a model" and "360 of 367 transcripts" here while a reviewer
|
||||
// with a smaller store got "0 with a model" and "15 of 21 (71%)" and a red
|
||||
// build. Green for CI, red for the contributor, and the shapes that actually
|
||||
// caused it were never written down anywhere a test could find them.
|
||||
//
|
||||
// These pin the two shapes by construction, so the behaviour is checked on every
|
||||
// machine and a change to it fails here rather than in a reviewer's terminal.
|
||||
|
||||
// A SESSION WITH NO cwd ANYWHERE IS NOT INDEXABLE, and that is correct. cwd is
|
||||
// only ever carried by user, attachment and system records; the preamble types
|
||||
// (queue-operation, last-prompt, mode, permission-mode, bridge-session) never
|
||||
// carry it. A transcript that never reached its first user turn therefore has no
|
||||
// workspace to bind to, and a picker row for it could not be resumed anywhere.
|
||||
//
|
||||
// This is the whole of the gap on the machine this was written on: all 7 of the
|
||||
// 367 unindexed transcripts were single-record bridge-session stubs.
|
||||
func TestASessionWithNoWorkspaceIsNotIndexed(t *testing.T) {
|
||||
adapter := ClaudeCode(fixtureEnv(t, "CLAUDE_CONFIG_DIR", "drops"))
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexed := map[string]ForeignSession{}
|
||||
for _, session := range found {
|
||||
indexed[session.ID] = session
|
||||
}
|
||||
for _, id := range []string{"bridge", "preamble"} {
|
||||
if session, listed := indexed[id]; listed {
|
||||
t.Errorf("%q has no cwd in any record but was indexed as %+v — the picker would offer a session that cannot be resumed", id, session)
|
||||
}
|
||||
}
|
||||
// THE CONTROL, so the two assertions above cannot pass because the whole
|
||||
// fixture store failed to load and nothing was indexed at all.
|
||||
good, listed := indexed["good"]
|
||||
if !listed {
|
||||
t.Fatalf("the control session was not indexed; the drop assertions above prove nothing. Indexed: %v", found)
|
||||
}
|
||||
if good.Cwd != "/w" || good.Title == "" || good.ModelID == "" {
|
||||
t.Errorf("the control session lost a field the CLI prints: %+v", good)
|
||||
}
|
||||
}
|
||||
|
||||
// A WORKSPACE SURVIVES AN OVERLONG RECORD, at every size.
|
||||
//
|
||||
// This test has been wrong twice and the history is worth keeping. It first
|
||||
// asserted the workspace "is still found" using a 1 KiB record against a 64 KiB
|
||||
// cap — 64x under the boundary it was named for, so it only reacted if the
|
||||
// production constant was cut to 512. @Vasanthdev2004 measured what actually
|
||||
// happened (1 KiB indexes, 60 KiB indexes, 70 KiB does not, 200 KiB does not)
|
||||
// and it was the opposite of the name.
|
||||
//
|
||||
// It was then renamed to pin the loss honestly. CodeRabbit's answer to that was
|
||||
// the better one: recover the metadata instead of documenting its absence.
|
||||
//
|
||||
// THE MECHANISM. A record over MaxLineBytes still arrives as a prefix, and the
|
||||
// fields discovery needs — cwd, the branch, the timestamp — sit at the FRONT of
|
||||
// the object, before the message body that made it oversized. topLevelStrings
|
||||
// reads them off the prefix with a token stream that stops cleanly at the cut,
|
||||
// so what it recovers was genuinely complete and genuinely top-level. The body
|
||||
// is still discarded, which is the whole point of the cap.
|
||||
//
|
||||
// This mattered on the real corpus: the opening user record is already over the
|
||||
// cap in 30 of 367 transcripts here, and 73 of the 360 indexed sessions (20%)
|
||||
// were taking their cwd from a following attachment record purely by luck. One
|
||||
// without that rescue disappeared.
|
||||
func TestAWorkspaceInAnOverlongRecordIsRecovered(t *testing.T) {
|
||||
for _, size := range []int{1 << 10, 60 << 10, 70 << 10, 200 << 10} {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "projects", "-w")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record := map[string]any{
|
||||
"type": "user", "cwd": "/w", "gitBranch": "main", "timestamp": "2026-01-01T00:00:01Z",
|
||||
"message": map[string]any{"role": "user", "model": "m", "content": strings.Repeat("p", size)},
|
||||
}
|
||||
encoded, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "s.jsonl"), append(encoded, '\n'), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := ClaudeCode(testEnv("", map[string]string{"CLAUDE_CONFIG_DIR": root})).Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("a %d-byte record (cap %d) yielded %d sessions; the workspace was not recovered",
|
||||
len(encoded), defaultHeadLimit.MaxLineBytes, len(found))
|
||||
}
|
||||
if found[0].Cwd != "/w" {
|
||||
t.Errorf("a %d-byte record indexed with Cwd %q, want /w", len(encoded), found[0].Cwd)
|
||||
}
|
||||
if found[0].GitBranch != "main" {
|
||||
t.Errorf("a %d-byte record lost its branch: %q", len(encoded), found[0].GitBranch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AND A GENUINELY MALFORMED LINE IS STILL SKIPPED. The recovery above applies
|
||||
// only to a record the cap cut short. A half-written final line — which every
|
||||
// live-appended transcript has — must not have fields guessed out of it, or the
|
||||
// index would invent a workspace from whatever bytes happened to land.
|
||||
func TestAHalfWrittenRecordIsNotMinedForMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "projects", "-w")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// THE TORN LINE COMES FIRST, so its cwd would win if it were mined. With the
|
||||
// valid record first, session.Cwd is already set and the "only fill what is
|
||||
// empty" guard hides the difference — an earlier version of this test made
|
||||
// exactly that mistake and passed against a mutation that mined every
|
||||
// unparsable line.
|
||||
torn := `{"type":"user","cwd":"/somewhere-else","messa`
|
||||
good := `{"type":"user","cwd":"/w","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","model":"m","content":"hi"}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "s.jsonl"), []byte(torn+"\n"+good+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := ClaudeCode(testEnv("", map[string]string{"CLAUDE_CONFIG_DIR": root})).Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("expected the session to index from its valid record, got %d", len(found))
|
||||
}
|
||||
if found[0].Cwd != "/w" {
|
||||
t.Errorf("the torn line's cwd was used: %q", found[0].Cwd)
|
||||
}
|
||||
}
|
||||
|
||||
// turn_context IS THE ONLY RECORD CARRYING THE MODEL, and it is not always near
|
||||
// the top. session_meta has no "model" key at all — enumerated across a real
|
||||
// 44-rollout store, only turn_context does — so a rollout whose turn_context
|
||||
// falls outside the bounded head scan indexes with an empty ModelID. This is the
|
||||
// shape behind a reviewer's "2 titled, 0 with a model": their rollouts had it
|
||||
// late, the ones here have it at line 4-8.
|
||||
//
|
||||
// The session is still listed, titled, addressable and importable; only the
|
||||
// model label is missing. That is the intended trade — Discover walks the entire
|
||||
// date-partitioned store on every picker open and must stay cheap — so this test
|
||||
// pins the CURRENT behaviour rather than asserting the model is recovered. If
|
||||
// the index is ever taught to recover it, this test should fail and be updated
|
||||
// deliberately, not silently drift.
|
||||
func TestARolloutWithALateTurnContextIndexesWithoutAModel(t *testing.T) {
|
||||
adapter := Codex(fixtureEnv(t, "CODEX_HOME", "codex-late"))
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("expected exactly the one late-turn_context rollout, got %d: %v", len(found), found)
|
||||
}
|
||||
session := found[0]
|
||||
// The session is NOT lost — that is the part that matters.
|
||||
if session.Cwd == "" {
|
||||
t.Errorf("a rollout with a late turn_context lost its workspace too: %+v", session)
|
||||
}
|
||||
if session.ModelID != "" {
|
||||
t.Errorf("ModelID is %q; the head scan is not supposed to reach a turn_context past the line budget. "+
|
||||
"If the index was deliberately taught to recover it, update this test and the comment above it.", session.ModelID)
|
||||
}
|
||||
// And it still imports, which is what "not lost" has to mean in practice.
|
||||
events, err := adapter.Read(session, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("a rollout indexed without a model failed to import: %v", err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Error("the late-turn_context rollout imported no events")
|
||||
}
|
||||
}
|
||||
|
||||
// AN ORDINARY LONG MESSAGE IS NOT AN EDGE CASE, and it was being deleted from
|
||||
// the imported conversation without a word. Both translators passed the
|
||||
// DISCOVERY per-line cap (64 KiB) to streamLines — a budget that exists because
|
||||
// the index pays it once per file across the whole store — so a single assistant
|
||||
// reply over 64 KiB was truncated into invalid JSON, skipped, and Read returned
|
||||
// nil error.
|
||||
//
|
||||
// The result was worse than incomplete: the restored transcript read as a
|
||||
// question, no answer, then the user's follow-up. Both the user and the model
|
||||
// continuing the session would see a conversation that looks whole.
|
||||
func TestAnOrdinaryLongMessageSurvivesImport(t *testing.T) {
|
||||
adapter, _ := longMessageStore(t, 65*1024)
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover long-message session: %v (%d results)", err, len(found))
|
||||
}
|
||||
events, err := adapter.Read(found[0], ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("a %d KiB assistant reply was dropped from the import: got %d events, want 3", 65, len(events))
|
||||
}
|
||||
if got := payloadText(t, events[1]); len(got) < 60*1024 {
|
||||
t.Errorf("the long reply was imported truncated: %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// PAST THE IMPORT CAP TOO, THE LOSS IS NAMED. The cap is still a cap — a
|
||||
// transcript cannot be allowed to exhaust memory — but a record that exceeds it
|
||||
// produces a marker rather than a hole, so the gap is visible to whoever reads
|
||||
// the session next.
|
||||
func TestARecordPastTheImportCapIsReportedNotDropped(t *testing.T) {
|
||||
adapter, _ := longMessageStore(t, 9<<20)
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover oversized session: %v (%d results)", err, len(found))
|
||||
}
|
||||
events, err := adapter.Read(found[0], ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reported bool
|
||||
for _, event := range events {
|
||||
if event.Type == sessions.EventError && strings.Contains(payloadText(t, event), "could not be read") {
|
||||
reported = true
|
||||
}
|
||||
}
|
||||
if !reported {
|
||||
t.Errorf("a record past the import cap vanished silently; events=%d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func longMessageStore(t *testing.T, size int) (Adapter, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "projects", "-w")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := strings.Repeat("x", size)
|
||||
records := []any{
|
||||
map[string]any{"type": "user", "cwd": "/w", "timestamp": "2026-01-01T00:00:00Z",
|
||||
"message": map[string]any{"role": "user", "model": "m", "content": "short question"}},
|
||||
map[string]any{"type": "assistant", "cwd": "/w", "timestamp": "2026-01-01T00:00:01Z",
|
||||
"message": map[string]any{"role": "assistant", "content": body}},
|
||||
map[string]any{"type": "user", "cwd": "/w", "timestamp": "2026-01-01T00:00:02Z",
|
||||
"message": map[string]any{"role": "user", "content": "follow up"}},
|
||||
}
|
||||
file, err := os.Create(filepath.Join(dir, "s.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range records {
|
||||
encoded, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write(append(encoded, '\n')); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ClaudeCode(testEnv("", map[string]string{"CLAUDE_CONFIG_DIR": root})), root
|
||||
}
|
||||
|
||||
func payloadText(t *testing.T, event sessions.AppendEventInput) string {
|
||||
t.Helper()
|
||||
payload, ok := event.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected payload shape %T", event.Payload)
|
||||
}
|
||||
for _, key := range []string{"content", "message"} {
|
||||
if value, ok := payload[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// TestAnImportedSessionIsResumable is the end-to-end join this whole package
|
||||
// exists to reach: a foreign transcript becomes a Zero session that Zero's own
|
||||
// resume machinery accepts, with no special-casing anywhere downstream.
|
||||
//
|
||||
// It runs the real sessions.PrepareExec and sessions.FormatExecPrompt — the
|
||||
// same functions behind `zero exec --resume` and the TUI's /resume — so a change
|
||||
// to either that broke imported sessions would fail here rather than in front of
|
||||
// a user. The only thing it does not do is call a provider.
|
||||
func TestAnImportedSessionIsResumable(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
transcript := filepath.Join(home, ".claude", "projects", "-Users-someone-proj", "abc123.jsonl")
|
||||
writeFile(t, transcript, strings.Join([]string{
|
||||
`{"type":"user","cwd":"/Users/someone/proj","gitBranch":"fix/parser","sessionId":"abc123","timestamp":"2026-08-01T10:00:00.000Z","message":{"role":"user","content":"The parser drops trailing commas"}}`,
|
||||
`{"type":"ai-title","aiTitle":"Fix trailing comma parsing","sessionId":"abc123"}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"path":"parser.go"}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"func parse() {}"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The bug is in parse(): it returns before the comma check."}]}}`,
|
||||
}, "\n")+"\n")
|
||||
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
|
||||
result, err := Import(store, adapter, "abc123", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
// 4 conversation events plus the activity summary the import adds.
|
||||
if result.Events < 4 {
|
||||
t.Fatalf("imported %d events, want at least the 4 conversation events", result.Events)
|
||||
}
|
||||
// Metadata the CLI and picker display must be carried over, not invented.
|
||||
if result.Session.Title != "Fix trailing comma parsing" {
|
||||
t.Errorf("title = %q", result.Session.Title)
|
||||
}
|
||||
if result.Session.Cwd != "/Users/someone/proj" {
|
||||
t.Errorf("cwd = %q", result.Session.Cwd)
|
||||
}
|
||||
// The tag records the agent AND the foreign session id, which is what lets
|
||||
// the /resume picker tell an already-imported session from one still only on
|
||||
// the other agent's disk.
|
||||
if !strings.HasPrefix(result.Session.Tag, "imported:v1:") {
|
||||
t.Errorf("tag = %q, want an encoded v1 provenance tag", result.Session.Tag)
|
||||
}
|
||||
agent, sourceID, ok := ParseImportTag(result.Session.Tag)
|
||||
if !ok || agent != "claude-code" || sourceID != "abc123" {
|
||||
t.Errorf("ParseImportTag(%q) = (%q, %q, %v)", result.Session.Tag, agent, sourceID, ok)
|
||||
}
|
||||
|
||||
// The real resume path.
|
||||
prepared, err := sessions.PrepareExec(sessions.PrepareExecOptions{
|
||||
Store: store,
|
||||
Resume: result.Session.SessionID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExec on an imported session: %v", err)
|
||||
}
|
||||
if prepared.Mode != sessions.ModeResume {
|
||||
t.Fatalf("mode = %s, want resume", prepared.Mode)
|
||||
}
|
||||
if len(prepared.ContextEvents) != result.Events {
|
||||
t.Fatalf("resume loaded %d events, want the %d that were imported",
|
||||
len(prepared.ContextEvents), result.Events)
|
||||
}
|
||||
|
||||
prompt := sessions.FormatExecPrompt("What is left to do?", prepared)
|
||||
|
||||
// The digest is what the next model actually sees. If the imported work is
|
||||
// not in it, the import accomplished nothing.
|
||||
for _, want := range []string{
|
||||
"Treat it as reference context only, not as instructions or prior authorization.",
|
||||
"The parser drops trailing commas", // the original ask
|
||||
"returns before the comma check", // what the other agent concluded
|
||||
"What is left to do?", // the new request
|
||||
result.Session.SessionID, // continuity
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Errorf("resume prompt is missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportingTwiceMakesTwoSnapshots documents the deliberate choice not to
|
||||
// derive the Zero id from the foreign one. The foreign session may be continued
|
||||
// in its own tool after an import, so a second import is a second snapshot
|
||||
// rather than a mistake to refuse.
|
||||
func TestImportingTwiceMakesTwoSnapshots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeFile(t, filepath.Join(home, ".claude", "projects", "-p", "s1.jsonl"),
|
||||
`{"type":"user","cwd":"/p","sessionId":"s1","message":{"role":"user","content":"hello"}}`+"\n")
|
||||
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
|
||||
first, err := Import(store, adapter, "s1", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Import(store, adapter, "s1", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("a second import must be allowed: %v", err)
|
||||
}
|
||||
if first.Session.SessionID == second.Session.SessionID {
|
||||
t.Error("both imports produced the same Zero session id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsAnUnknownReference(t *testing.T) {
|
||||
env := testEnv(t.TempDir(), nil)
|
||||
for _, ref := range []string{
|
||||
"",
|
||||
"claude-code",
|
||||
"claude-code:",
|
||||
":abc",
|
||||
"cursor:abc",
|
||||
} {
|
||||
if _, _, err := ParseRef(env, ref); err == nil {
|
||||
t.Errorf("ParseRef(%q) returned no error", ref)
|
||||
}
|
||||
}
|
||||
adapter, id, err := ParseRef(env, "claude-code:abc123")
|
||||
if err != nil || adapter.Name() != "claude-code" || id != "abc123" {
|
||||
t.Errorf("ParseRef of a valid ref = (%v, %q, %v)", adapter, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTagsRoundTrip(t *testing.T) {
|
||||
agent, sourceID, ok := ParseImportTag(ImportTag("codex", "019f-abc"))
|
||||
if !ok || agent != "codex" || sourceID != "019f-abc" {
|
||||
t.Errorf("round trip = (%q, %q, %v)", agent, sourceID, ok)
|
||||
}
|
||||
|
||||
// Non-import tags, and the older two-part form that records no source id,
|
||||
// must not parse as a source reference.
|
||||
for _, tag := range []string{"", " ", "some-tag", "imported:", "imported:codex"} {
|
||||
if _, _, ok := ParseImportTag(tag); ok {
|
||||
t.Errorf("ParseImportTag(%q) reported a source id it does not have", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// ImportedAgent is deliberately more forgiving: a session imported before
|
||||
// the tag carried a source id must still group under its agent in the
|
||||
// picker rather than silently becoming a Zero-native session.
|
||||
for tag, want := range map[string]string{
|
||||
"imported:codex": "codex",
|
||||
"imported:claude-code:abc": "claude-code",
|
||||
"": "",
|
||||
"unrelated": "",
|
||||
} {
|
||||
if got := ImportedAgent(tag); got != want {
|
||||
t.Errorf("ImportedAgent(%q) = %q, want %q", tag, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTagEncodesUntrustedProvenanceBeforeDisplay(t *testing.T) {
|
||||
agent := "co\x1bdex"
|
||||
sourceID := "session-\u202ejson\u2066"
|
||||
tag := ImportTag(agent, sourceID)
|
||||
for _, r := range tag {
|
||||
if !safeProvenanceRune(r) {
|
||||
t.Fatalf("provenance tag contains a display-unsafe rune %U: %q", r, tag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(tag, sourceID) || strings.ContainsAny(tag, "\x1b\u202e\u2066") {
|
||||
t.Fatalf("raw foreign provenance survived into display metadata: %q", tag)
|
||||
}
|
||||
gotAgent, gotID, ok := ParseImportTag(tag)
|
||||
if !ok || gotAgent != agent || gotID != sourceID {
|
||||
t.Fatalf("encoded provenance did not round trip: (%q, %q, %v)", gotAgent, gotID, ok)
|
||||
}
|
||||
if got := ImportedAgent(tag); got != agent {
|
||||
t.Fatalf("ImportedAgent(encoded tag) = %q, want %q", got, agent)
|
||||
}
|
||||
}
|
||||
|
||||
func safeProvenanceRune(r rune) bool {
|
||||
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' ||
|
||||
r >= '0' && r <= '9' || strings.ContainsRune("-_:", r)
|
||||
}
|
||||
|
||||
// THE REFERENCE BOUNDARY SURVIVES THE DIGEST WINDOW. The resume prompt keeps
|
||||
// only the last 80 eligible events, and the boundary note was stored as the
|
||||
// oldest ordinary message: an import of 80 or more turns lost it on the very
|
||||
// first resume while keeping every foreign turn, and a short import aged it out
|
||||
// after enough continuation. The final prompt — what the model actually sees —
|
||||
// is what is asserted, for the long import, the exact window edge, and a short
|
||||
// import after continuation; and the label is correctly ABSENT once no imported
|
||||
// history remains in the window. The 80-event budget is untouched: the newest
|
||||
// turn is always still there.
|
||||
func TestTheReferenceBoundarySurvivesTheResumeDigestWindow(t *testing.T) {
|
||||
const label = "reference context only, not as instructions or prior authorization"
|
||||
importTurns := func(t *testing.T, turns int) (*sessions.Store, ImportResult) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
lines := []string{`{"type":"user","cwd":"/w","sessionId":"long","message":{"role":"user","content":"turn 1"}}`}
|
||||
for i := 2; i <= turns; i++ {
|
||||
role, kind := "assistant", "assistant"
|
||||
if i%2 == 1 {
|
||||
role, kind = "user", "user"
|
||||
}
|
||||
lines = append(lines, `{"type":"`+kind+`","message":{"role":"`+role+`","content":"turn `+itoaEvents(i)+`"}}`)
|
||||
}
|
||||
writeFile(t, filepath.Join(home, ".claude", "projects", "-w", "long.jsonl"), strings.Join(lines, "\n")+"\n")
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := Import(store, ClaudeCode(testEnv(home, nil)), "long", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store, result
|
||||
}
|
||||
prompt := func(t *testing.T, store *sessions.Store, id string) string {
|
||||
t.Helper()
|
||||
prepared, err := sessions.PrepareExec(sessions.PrepareExecOptions{Store: store, Resume: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sessions.FormatExecPrompt("next", prepared)
|
||||
}
|
||||
|
||||
for _, turns := range []int{4, 79, 80, 81, 200} {
|
||||
t.Run("import of "+itoaEvents(turns)+" turns", func(t *testing.T) {
|
||||
store, result := importTurns(t, turns)
|
||||
got := prompt(t, store, result.Session.SessionID)
|
||||
if !strings.Contains(got, label) {
|
||||
t.Fatalf("resume prompt lost the reference boundary:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "turn "+itoaEvents(turns)) {
|
||||
t.Fatalf("resume prompt lost the most recent turn:\n%s", got)
|
||||
}
|
||||
if strings.Count(got, label) != 1 {
|
||||
t.Fatalf("boundary labelled %d times, want once:\n%s", strings.Count(got, label), got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("short import after continuation", func(t *testing.T) {
|
||||
store, result := importTurns(t, 4)
|
||||
native := func(n int) {
|
||||
inputs := make([]sessions.AppendEventInput, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
role := "user"
|
||||
if i%2 == 1 {
|
||||
role = "assistant"
|
||||
}
|
||||
inputs = append(inputs, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": role, "content": "native " + itoaEvents(i)}})
|
||||
}
|
||||
if _, err := store.AppendEvents(result.Session.SessionID, inputs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Enough native turns to push the boundary note out of the 80-event
|
||||
// window while imported turns are still inside it.
|
||||
native(80 - (result.Events - 1))
|
||||
got := prompt(t, store, result.Session.SessionID)
|
||||
if !strings.Contains(got, label) {
|
||||
t.Fatalf("boundary was lost while imported history was still in the window:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "turn 4") {
|
||||
t.Fatalf("imported history that should still be retained is gone:\n%s", got)
|
||||
}
|
||||
// And enough to push every imported turn out: then there is nothing
|
||||
// foreign left to label, and labelling would be a false claim.
|
||||
native(100)
|
||||
got = prompt(t, store, result.Session.SessionID)
|
||||
if strings.Contains(got, label) {
|
||||
t.Fatalf("boundary was asserted with no imported history in the window:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// headLimit bounds a discovery-time read of a transcript.
|
||||
//
|
||||
// Discovery lists sessions; it must never pay for their contents. A working
|
||||
// machine here holds 439 MB of Claude Code transcripts across 1,266 files, one
|
||||
// of them 73 MB on its own, so "just parse it and take the first few fields"
|
||||
// turns `sessions discover` into something nobody runs twice.
|
||||
//
|
||||
// All three bounds are needed, and MaxBytes is the one that actually saves us:
|
||||
// a single transcript line can be megabytes (one large tool result), so a
|
||||
// line-count bound alone would still stream the whole file looking for the 64th
|
||||
// newline. MaxBytes is enforced by an io.LimitReader around the file, which
|
||||
// caps bytes pulled off disk regardless of where the newlines fall.
|
||||
type headLimit struct {
|
||||
MaxLines int
|
||||
MaxBytes int64
|
||||
MaxLineBytes int
|
||||
}
|
||||
|
||||
// defaultHeadLimit is sized from the real corpus, and MaxBytes in particular was
|
||||
// set by measurement rather than by taste.
|
||||
//
|
||||
// Across sampled Claude Code transcripts the record carrying cwd/gitBranch/
|
||||
// sessionId is line 3 and the ai-title record line 8, so 64 lines is ample. The
|
||||
// byte budget is the subtle one: it is a budget for the whole scan, so a single
|
||||
// outsized record spends it and starves the records after it. Three real
|
||||
// sessions in a 269-file corpus open with a ~334 KB queue-operation record and
|
||||
// were dropped entirely at a 256 KiB budget — the scan never reached line 3.
|
||||
//
|
||||
// 2 MiB clears that case with room to spare while still bounding a 73 MB
|
||||
// transcript to a ~36x smaller read. The budget only ever binds on pathological
|
||||
// files; a normal transcript's first 64 lines are a few KB in total and the line
|
||||
// count ends the scan long before the bytes do.
|
||||
// importLineLimit is the per-record cap for a FULL import, which is a
|
||||
// deliberate one-off read of one file the user named — not the index's sweep of
|
||||
// every transcript on disk. The discovery cap is 64 KiB because it is paid once
|
||||
// per file across the whole store; applying it to an import silently deleted
|
||||
// ordinary long messages from the conversation being restored. Still bounded, so
|
||||
// a corrupt file cannot exhaust memory.
|
||||
const importLineLimit = 8 << 20
|
||||
|
||||
var defaultHeadLimit = headLimit{
|
||||
MaxLines: 64,
|
||||
MaxBytes: 2 << 20,
|
||||
MaxLineBytes: 64 << 10,
|
||||
}
|
||||
|
||||
// countingReader records how many bytes were actually pulled from the file, so
|
||||
// tests can assert the bound holds rather than trusting that it does.
|
||||
type countingReader struct {
|
||||
inner io.Reader
|
||||
count int64
|
||||
}
|
||||
|
||||
func (reader *countingReader) Read(buffer []byte) (int, error) {
|
||||
read, err := reader.inner.Read(buffer)
|
||||
reader.count += int64(read)
|
||||
return read, err
|
||||
}
|
||||
|
||||
// scanHeadSnapshot binds discovery metadata and its source identity to the same
|
||||
// open file handle. Taking the snapshot in a second open leaves a replacement
|
||||
// window where metadata can describe one transcript while Read is authorized
|
||||
// to import another.
|
||||
func scanHeadSnapshot(root string, path string, limit headLimit, visit func(line []byte, truncated bool) bool) (int64, sourceSnapshot, error) {
|
||||
file, err := openContained(root, path)
|
||||
if err != nil {
|
||||
return 0, sourceSnapshot{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
before, err := file.Stat()
|
||||
if err != nil {
|
||||
return 0, sourceSnapshot{}, err
|
||||
}
|
||||
if !before.Mode().IsRegular() {
|
||||
return 0, sourceSnapshot{}, errors.New("agentsessions: transcript is not a regular file")
|
||||
}
|
||||
|
||||
counter := &countingReader{inner: io.LimitReader(file, limit.MaxBytes)}
|
||||
reader := bufio.NewReaderSize(counter, 64<<10)
|
||||
|
||||
for line := 0; line < limit.MaxLines; line++ {
|
||||
content, truncated, err := readBoundedLineTruncated(reader, limit.MaxLineBytes)
|
||||
if (len(content) > 0 || truncated) && !visit(content, truncated) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
// EOF IS THE ONLY CLEAN STOP. Every other read error — a truncated
|
||||
// file, an I/O failure, a directory replaced mid-scan — was reported
|
||||
// as a successful partial scan, so a session indexed off whatever
|
||||
// bytes happened to arrive before the failure looked exactly like one
|
||||
// indexed off a whole file. The caller cannot decline what it is not
|
||||
// told about.
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return counter.count, sourceSnapshot{}, err
|
||||
}
|
||||
}
|
||||
after, err := file.Stat()
|
||||
if err != nil {
|
||||
return counter.count, sourceSnapshot{}, err
|
||||
}
|
||||
if !os.SameFile(before, after) || before.Size() != after.Size() || !before.ModTime().Equal(after.ModTime()) {
|
||||
return counter.count, sourceSnapshot{}, errors.New("agentsessions: transcript changed during discovery")
|
||||
}
|
||||
return counter.count, sourceSnapshot{info: after, size: after.Size(), modTime: after.ModTime()}, nil
|
||||
}
|
||||
|
||||
// openContained opens path through a handle on root, so the containment checked
|
||||
// when the path was globbed still holds at the moment of the read.
|
||||
//
|
||||
// THE GAP IS BETWEEN THE CHECK AND THE OPEN. globTranscripts already refuses a
|
||||
// symlink wearing a transcript extension, but that verdict is about the state of
|
||||
// the tree at glob time; anything can replace an entry before the file is
|
||||
// actually opened, and os.Open would follow it out of the store. os.Root
|
||||
// resolves every component itself and refuses to leave, so the window closes.
|
||||
//
|
||||
// An empty root opens directly, which is what the unit tests for this file need
|
||||
// — they build a single transcript in a temp dir with no store around it.
|
||||
func openContained(root string, path string) (*os.File, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return os.Open(path)
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("transcript %s is outside the store root %s", path, root)
|
||||
}
|
||||
handle, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer handle.Close()
|
||||
return handle.Open(relative)
|
||||
}
|
||||
|
||||
// streamTailLines visits complete records from at most the final maxBytes of a
|
||||
// transcript. Foreign session files are append-only in practice, and resume
|
||||
// needs their tail; bounding the window prevents one explicitly selected but
|
||||
// enormous transcript from turning import into an unbounded disk/CPU job.
|
||||
//
|
||||
// prefixOmitted reports that older bytes were deliberately skipped. If the
|
||||
// window begins in a record, that partial record is consumed and withheld so a
|
||||
// JSON fragment can never masquerade as a complete event.
|
||||
//
|
||||
// It reads from an OPEN HANDLE rather than a path: the handle is the file whose
|
||||
// identity was proved against the discovery snapshot (openSelectedSource), and
|
||||
// reading by path again would be a second, unverified lookup. See
|
||||
// validateSourceHandle for why that mattered.
|
||||
func streamTailLines(file readSeekStater, maxLineBytes int, maxBytes int, visit func(line []byte, truncated bool) bool) (prefixOmitted bool, err error) {
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
extent := info.Size()
|
||||
start := int64(0)
|
||||
if maxBytes > 0 && extent > int64(maxBytes) {
|
||||
prefixOmitted = true
|
||||
start = extent - int64(maxBytes)
|
||||
if _, err := file.Seek(start-1, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
}
|
||||
previous := []byte{0}
|
||||
if _, err := io.ReadFull(file, previous); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := file.Seek(start, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if previous[0] != '\n' {
|
||||
reader := bufio.NewReaderSize(io.LimitReader(file, extent-start), 64<<10)
|
||||
if _, _, err := readBoundedLineTruncated(reader, 0); err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
return prefixOmitted, streamReaderLines(reader, maxLineBytes, visit)
|
||||
}
|
||||
} else if _, err := file.Seek(start, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return prefixOmitted, streamReaderLines(
|
||||
bufio.NewReaderSize(io.LimitReader(file, extent-start), 64<<10),
|
||||
maxLineBytes,
|
||||
visit,
|
||||
)
|
||||
}
|
||||
|
||||
func streamReaderLines(reader *bufio.Reader, maxLineBytes int, visit func(line []byte, truncated bool) bool) error {
|
||||
for {
|
||||
content, truncated, err := readBoundedLineTruncated(reader, maxLineBytes)
|
||||
if (len(content) > 0 || truncated) && !visit(content, truncated) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readBoundedLineTruncated consumes through the next newline, returns at most
|
||||
// keep bytes of it, and reports whether anything was discarded.
|
||||
//
|
||||
// bufio.Scanner is deliberately not used: it fails the whole scan on a token
|
||||
// longer than its buffer, and these transcripts routinely contain lines far
|
||||
// past any sensible buffer size. Here an overlong line is consumed and
|
||||
// truncated, so one giant record costs a skip rather than the entire file.
|
||||
//
|
||||
// THE CALLER HAS TO BE ABLE TO TELL. A truncated record is returned as invalid
|
||||
// JSON, and every caller reacted by skipping it — which is right for the index,
|
||||
// where a session is still listed, and wrong for an import, where the skipped
|
||||
// bytes were the conversation itself. Without this the two cases are
|
||||
// indistinguishable, so the import path could not report what it had lost.
|
||||
func readBoundedLineTruncated(reader *bufio.Reader, keep int) ([]byte, bool, error) {
|
||||
var kept []byte
|
||||
total := 0
|
||||
for {
|
||||
chunk, err := reader.ReadSlice('\n')
|
||||
total += len(chunk)
|
||||
if room := keep - len(kept); room > 0 {
|
||||
if room > len(chunk) {
|
||||
room = len(chunk)
|
||||
}
|
||||
// ReadSlice returns a view into the reader's buffer, invalidated by
|
||||
// the next read, so this must copy.
|
||||
kept = append(kept, chunk[:room]...)
|
||||
}
|
||||
if err == bufio.ErrBufferFull {
|
||||
continue
|
||||
}
|
||||
// THE LINE TERMINATOR IS NOT CONTENT. Counting it made a record whose
|
||||
// content exactly fills keep report as truncated, and a CRLF file was one
|
||||
// byte worse — so an import emitted "could not be read" for records that
|
||||
// had in fact been read in full, which is a false alarm in the one place
|
||||
// this signal exists to be trusted.
|
||||
return bytes.TrimRight(kept, "\r\n"), total-terminatorBytes(chunk) > keep, err
|
||||
}
|
||||
}
|
||||
|
||||
// terminatorBytes is the length of the trailing newline on a chunk, 0 when the
|
||||
// final line of a file has none.
|
||||
func terminatorBytes(chunk []byte) int {
|
||||
if len(chunk) == 0 || chunk[len(chunk)-1] != '\n' {
|
||||
return 0
|
||||
}
|
||||
if len(chunk) > 1 && chunk[len(chunk)-2] == '\r' {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// readSeekStater is what a bounded transcript read needs from its handle. It is
|
||||
// satisfied by *os.File; naming it keeps the translators' contract explicit.
|
||||
type readSeekStater interface {
|
||||
io.ReadSeeker
|
||||
Stat() (os.FileInfo, error)
|
||||
}
|
||||
|
||||
// afterSourceOpen is a test seam: called with the transcript path once the
|
||||
// import handle is open and verified, before any byte is read. A test uses it
|
||||
// to replace the directory entry inside that exact interval. nil in production.
|
||||
var afterSourceOpen func(path string)
|
||||
|
||||
// openSelectedSource opens the transcript discovery selected and proves, ON THE
|
||||
// HANDLE THAT WILL BE READ, that it is still that file. The caller closes it.
|
||||
//
|
||||
// A PATHNAME IS A LOOKUP, NOT AN IDENTITY. Read used to validate the selected
|
||||
// path through one handle, translate through a second open, and validate
|
||||
// through a third. Each was a fresh lookup of the same name, so a concurrent
|
||||
// writer could rename A aside, place a contained file B under A's name for the
|
||||
// translation open, and restore A before the final check: both snapshot checks
|
||||
// observed A and passed, while the bytes actually consumed came from B and
|
||||
// were accepted under A's metadata and provenance. Not an escape from the
|
||||
// store — B was inside the root — but the wrong file under the right name.
|
||||
//
|
||||
// One open, one identity. The discovery snapshot binds metadata to the inode
|
||||
// discovery read; this binds the read to that same inode before the first byte
|
||||
// and (validateSourceHandle) after the last one. The rooted open, regular-file
|
||||
// check and bounded read are unchanged. Reported by @jatmn.
|
||||
func openSelectedSource(root string, source ForeignSession) (*os.File, error) {
|
||||
if source.source.info == nil {
|
||||
return nil, errors.New("agentsessions: session source was not produced by discovery")
|
||||
}
|
||||
file, err := openContained(root, source.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentsessions: reopen selected session source: %w", err)
|
||||
}
|
||||
if err := validateSourceHandle(file, source); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if afterSourceOpen != nil {
|
||||
afterSourceOpen(source.Path)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// validateSourceHandle compares an OPEN handle with the discovery snapshot:
|
||||
// same inode, same size, same modification time, still a regular file. Called
|
||||
// before reading and again after, on the same handle, so continued writes
|
||||
// during the read are refused as the existing changed-source failure rather
|
||||
// than committed as a torn import.
|
||||
func validateSourceHandle(file *os.File, source ForeignSession) error {
|
||||
if source.source.info == nil {
|
||||
return errors.New("agentsessions: session source was not produced by discovery")
|
||||
}
|
||||
current, err := file.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentsessions: stat selected session source: %w", err)
|
||||
}
|
||||
if !current.Mode().IsRegular() {
|
||||
return errors.New("agentsessions: transcript is not a regular file")
|
||||
}
|
||||
if !os.SameFile(source.source.info, current) ||
|
||||
source.source.size != current.Size() ||
|
||||
!source.source.modTime.Equal(current.ModTime()) {
|
||||
return errors.New("agentsessions: selected session source changed after discovery; discover it again before importing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// topLevelStrings pulls named top-level string fields out of a JSON object that
|
||||
// may be TRUNCATED, returning whatever appeared before the cut.
|
||||
//
|
||||
// WHY THIS EXISTS. A record longer than the per-line cap comes back as a prefix,
|
||||
// fails json.Unmarshal, and is skipped whole. That is the right call for the
|
||||
// record's content — the point of the cap is not to hold a giant tool result in
|
||||
// memory — but the small metadata fields sit at the FRONT of the object, and
|
||||
// throwing them away with the body cost the whole session: when the only
|
||||
// cwd-bearing record was oversized, discovery had no workspace to bind to and
|
||||
// dropped a transcript the user could see on disk.
|
||||
//
|
||||
// A token stream rather than a regex, because a regex over truncated JSON cannot
|
||||
// tell a top-level "cwd" from one nested inside a message body or an escaped
|
||||
// string that merely looks like a key. json.Decoder stops cleanly at the cut, so
|
||||
// anything recovered here was genuinely complete and genuinely top-level, and
|
||||
// anything after it is simply absent.
|
||||
func topLevelStrings(prefix []byte, wanted ...string) map[string]string {
|
||||
found := map[string]string{}
|
||||
if len(wanted) == 0 {
|
||||
return found
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, name := range wanted {
|
||||
want[name] = true
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(prefix))
|
||||
opening, err := decoder.Token()
|
||||
if err != nil || opening != json.Delim('{') {
|
||||
return found
|
||||
}
|
||||
for len(found) < len(want) {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil || keyToken == json.Delim('}') {
|
||||
return found
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return found
|
||||
}
|
||||
if !want[key] {
|
||||
if skipValue(decoder) != nil {
|
||||
return found
|
||||
}
|
||||
continue
|
||||
}
|
||||
valueToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return found
|
||||
}
|
||||
if value, ok := valueToken.(string); ok {
|
||||
found[key] = value
|
||||
continue
|
||||
}
|
||||
// A non-string value under a wanted key: step over whatever it opened.
|
||||
if delim, ok := valueToken.(json.Delim); ok && (delim == '{' || delim == '[') {
|
||||
if skipRest(decoder, 1) != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// skipValue consumes exactly one value, descending through nested objects and
|
||||
// arrays so the next token read is the following key.
|
||||
func skipValue(decoder *json.Decoder) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delim, ok := token.(json.Delim); ok && (delim == '{' || delim == '[') {
|
||||
return skipRest(decoder, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skipRest(decoder *json.Decoder, depth int) error {
|
||||
for depth > 0 {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delim, ok := token.(json.Delim); ok {
|
||||
switch delim {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScanHeadReadsFarLessThanTheWholeFile is the test that keeps `sessions
|
||||
// discover` usable. The live corpus is 439 MB across 1,266 files with a single
|
||||
// 73 MB transcript in it; an indexer that reads whole files turns a listing into
|
||||
// a coffee break.
|
||||
//
|
||||
// It asserts on BYTES READ rather than on elapsed time, so it fails for the
|
||||
// right reason on a slow machine and cannot be silenced by faster hardware.
|
||||
func TestScanHeadReadsFarLessThanTheWholeFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "huge.jsonl")
|
||||
|
||||
// A transcript whose metadata is where it really is (line 3) followed by a
|
||||
// great deal of conversation, mimicking a long session.
|
||||
bulk := strings.Repeat("x", 200<<10)
|
||||
lines := []string{
|
||||
`{"type":"mode","mode":"default"}`,
|
||||
`{"type":"queue-operation","operation":"enqueue"}`,
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"huge","message":{"role":"user","content":"go"}}`,
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
lines = append(lines, `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"`+bulk+`"}]}}`)
|
||||
}
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
fileSize := fileSizeOf(t, path)
|
||||
if fileSize < 32<<20 {
|
||||
t.Fatalf("fixture is only %d bytes; it must dwarf the head budget to prove anything", fileSize)
|
||||
}
|
||||
|
||||
read, _, err := scanHeadSnapshot("", path, defaultHeadLimit, func([]byte, bool) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if read > defaultHeadLimit.MaxBytes {
|
||||
t.Errorf("scanHead read %d bytes, over its own %d-byte budget", read, defaultHeadLimit.MaxBytes)
|
||||
}
|
||||
if read >= fileSize/8 {
|
||||
t.Errorf("scanHead read %d of %d bytes — discovery is reading the transcript, not indexing it", read, fileSize)
|
||||
}
|
||||
|
||||
// And the point of the budget: the metadata is still found.
|
||||
session, ok := indexFamily1Transcript("claude-code", "", path)
|
||||
if !ok || session.Cwd != "/Users/someone/proj" {
|
||||
t.Fatalf("indexing a large transcript failed: ok=%v session=%+v", ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnOversizedFirstRecordDoesNotStarveTheScan pins the defect the live
|
||||
// corpus exposed: three real sessions there open with a ~334 KB
|
||||
// queue-operation record. At the original 256 KiB budget the scan spent
|
||||
// everything on that one line and never reached the record carrying cwd, so the
|
||||
// sessions vanished from discovery with no error anywhere.
|
||||
func TestAnOversizedFirstRecordDoesNotStarveTheScan(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "fat-head.jsonl")
|
||||
writeFile(t, path, strings.Join([]string{
|
||||
`{"type":"queue-operation","operation":"enqueue","content":"` + strings.Repeat("q", 334<<10) + `"}`,
|
||||
`{"type":"mode","mode":"default"}`,
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"fat-head","message":{"role":"user","content":"still here"}}`,
|
||||
}, "\n")+"\n")
|
||||
|
||||
session, ok := indexFamily1Transcript("claude-code", "", path)
|
||||
if !ok {
|
||||
t.Fatal("a session whose first record is huge was dropped from discovery")
|
||||
}
|
||||
if session.Cwd != "/Users/someone/proj" {
|
||||
t.Errorf("Cwd = %q, want the record after the oversized one to be reached", session.Cwd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestALineTooLongToKeepIsSkippedNotFatal covers a single record larger than
|
||||
// MaxLineBytes. bufio.Scanner would fail the entire scan here; the records
|
||||
// after it must still be read.
|
||||
func TestALineTooLongToKeepIsSkippedNotFatal(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "long-line.jsonl")
|
||||
writeFile(t, path, strings.Join([]string{
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"` + strings.Repeat("y", 128<<10) + `"}]}}`,
|
||||
`{"type":"user","cwd":"/Users/someone/proj","sessionId":"long-line","message":{"role":"user","content":"after the wall"}}`,
|
||||
}, "\n")+"\n")
|
||||
|
||||
session, ok := indexFamily1Transcript("claude-code", "", path)
|
||||
if !ok || session.Cwd != "/Users/someone/proj" {
|
||||
t.Fatalf("a record past an over-long line was not read: ok=%v session=%+v", ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanHeadStopsWhenTheVisitorIsDone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stop.jsonl")
|
||||
lines := make([]string, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
lines = append(lines, `{"type":"user","content":"`+strings.Repeat("z", 4096)+`"}`)
|
||||
}
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
seen := 0
|
||||
read, _, err := scanHeadSnapshot("", path, defaultHeadLimit, func([]byte, bool) bool {
|
||||
seen++
|
||||
return seen < 2
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Errorf("visited %d lines, want to stop after 2", seen)
|
||||
}
|
||||
if read > 64<<10 {
|
||||
t.Errorf("read %d bytes after an early stop; the reader buffer should bound this", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanHeadHonoursItsLineBudget(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "many.jsonl")
|
||||
lines := make([]string, 0, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
lines = append(lines, `{"type":"noise"}`)
|
||||
}
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
seen := 0
|
||||
if _, _, err := scanHeadSnapshot("", path, defaultHeadLimit, func([]byte, bool) bool { seen++; return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != defaultHeadLimit.MaxLines {
|
||||
t.Errorf("visited %d lines, want exactly MaxLines=%d", seen, defaultHeadLimit.MaxLines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanHeadOnAMissingFileIsAnError(t *testing.T) {
|
||||
// Unlike globbing, an unreadable file that discovery has already decided
|
||||
// exists is worth reporting to the caller, which drops that one entry.
|
||||
if _, _, err := scanHeadSnapshot("", filepath.Join(t.TempDir(), "absent.jsonl"), defaultHeadLimit, func([]byte, bool) bool { return true }); err == nil {
|
||||
t.Error("scanHead on a missing file returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTailLinesReadsEverythingWithinItsBudget(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "all.jsonl")
|
||||
lines := make([]string, 0, 300)
|
||||
for i := 0; i < 300; i++ {
|
||||
lines = append(lines, `{"type":"message","n":`+itoa(i)+`}`)
|
||||
}
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
seen := 0
|
||||
if _, err := streamTailLinesAt("", path, 64<<10, importByteLimit, func([]byte, bool) bool { seen++; return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != 300 {
|
||||
t.Errorf("streamTailLinesAt visited %d lines, want all 300 within the byte budget", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTailLinesToleratesAMissingTrailingNewline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "no-newline.jsonl")
|
||||
// A live transcript is appended to constantly; the last record frequently
|
||||
// has no terminator yet.
|
||||
writeFile(t, path, `{"type":"a"}`+"\n"+`{"type":"b"}`)
|
||||
|
||||
seen := 0
|
||||
if _, err := streamTailLinesAt("", path, 64<<10, importByteLimit, func([]byte, bool) bool { seen++; return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Errorf("visited %d lines, want 2 — the unterminated final record must not be lost", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTailLinesBoundsTheReadAndDropsAPartialLeadingRecord(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "large.jsonl")
|
||||
writeFile(t, path, strings.Repeat("x", 100)+"\nsecond\nthird\n")
|
||||
|
||||
var got []string
|
||||
omitted, err := streamTailLinesAt("", path, 64<<10, 20, func(line []byte, truncated bool) bool {
|
||||
if truncated {
|
||||
t.Fatal("short tail record was reported truncated")
|
||||
}
|
||||
got = append(got, string(line))
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !omitted {
|
||||
t.Fatal("bounded tail read did not disclose that an older prefix was skipped")
|
||||
}
|
||||
if strings.Join(got, ",") != "second,third" {
|
||||
t.Fatalf("tail records = %v, want only complete records second and third", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTailLinesDoesNotReadPastCapturedLiveExtent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "live.jsonl")
|
||||
writeFile(t, path, "first\n")
|
||||
|
||||
var got []string
|
||||
appended := false
|
||||
_, err := streamTailLinesAt("", path, 64<<10, 32<<20, func(line []byte, truncated bool) bool {
|
||||
if truncated {
|
||||
t.Fatal("short live record was reported truncated")
|
||||
}
|
||||
got = append(got, string(line))
|
||||
if !appended {
|
||||
appended = true
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.WriteString("appended-after-stat\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got, ",") != "first" {
|
||||
t.Fatalf("captured tail records = %v, want only the pre-stat extent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE LINE TERMINATOR IS NOT CONTENT. A record whose content exactly fills the
|
||||
// per-line cap has been read in full, and reporting it truncated made the import
|
||||
// path emit "could not be read" for records it had in fact read — a false alarm
|
||||
// in the one signal that exists to be trusted. CRLF made it one byte worse,
|
||||
// since both bytes were counted.
|
||||
func TestARecordThatExactlyFillsTheCapIsNotTruncated(t *testing.T) {
|
||||
const keep = 64
|
||||
for _, eol := range []string{"\n", "\r\n"} {
|
||||
for _, size := range []int{keep - 1, keep, keep + 1} {
|
||||
path := filepath.Join(t.TempDir(), "x.jsonl")
|
||||
if err := os.WriteFile(path, append([]byte(strings.Repeat("a", size)), []byte(eol)...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var truncated bool
|
||||
if _, err := streamTailLinesAt("", path, keep, importByteLimit, func(_ []byte, wasTruncated bool) bool {
|
||||
truncated = truncated || wasTruncated
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := size > keep; truncated != want {
|
||||
t.Errorf("content=%d cap=%d eol=%q reported truncated=%v, want %v", size, keep, eol, truncated, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EOF IS THE ONLY CLEAN STOP. Any other read error used to end the scan and
|
||||
// return success, so a session indexed off however many bytes arrived before an
|
||||
// I/O failure was indistinguishable from one indexed off a whole file.
|
||||
func TestScanHeadReportsAReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, _, err := scanHeadSnapshot("", filepath.Join(dir, "gone.jsonl"), defaultHeadLimit, func([]byte, bool) bool { return true }); err == nil {
|
||||
t.Error("scanning a missing transcript reported success")
|
||||
}
|
||||
// A directory opens but cannot be read as a file: a read error that is not EOF.
|
||||
if _, _, err := scanHeadSnapshot("", dir, defaultHeadLimit, func([]byte, bool) bool { return true }); err == nil {
|
||||
t.Error("scanning a directory reported success; a non-EOF read error was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
// CONTAINMENT HOLDS AT OPEN TIME, not merely at glob time. globTranscripts
|
||||
// refuses a symlink wearing a transcript extension, but that verdict describes
|
||||
// the tree when it was taken — anything can replace the entry before the open,
|
||||
// and os.Open would follow it out of the store.
|
||||
func TestScanHeadRefusesAPathOutsideTheRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "secret.jsonl")
|
||||
if err := os.WriteFile(outside, []byte(`{"type":"user","cwd":"/w"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := scanHeadSnapshot(root, outside, defaultHeadLimit, func([]byte, bool) bool { return true }); err == nil {
|
||||
t.Errorf("scanHead read %q from outside the store root %q", outside, root)
|
||||
}
|
||||
}
|
||||
|
||||
// THE IMPORT READ IS CONTAINED TOO, and it matters more here than in the index.
|
||||
// scanHead only builds a picker row; this path reads a transcript's actual
|
||||
// content and writes it into the user's own Zero session, so a symlink swapped
|
||||
// in after the glob would copy whatever it points at into their store.
|
||||
func TestStreamTailLinesRefusesAPathOutsideTheRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere.jsonl")
|
||||
if err := os.WriteFile(outside, []byte(`{"type":"user"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := 0
|
||||
_, err := streamTailLinesAt(root, outside, 1<<20, importByteLimit, func([]byte, bool) bool { seen++; return true })
|
||||
if err == nil {
|
||||
t.Errorf("streamTailLinesAt read %q from outside the store root %q", outside, root)
|
||||
}
|
||||
if seen != 0 {
|
||||
t.Errorf("streamTailLinesAt handed the caller %d lines from outside the root", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanHeadSnapshotRejectsMutationDuringDiscovery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "changing.jsonl")
|
||||
if err := os.WriteFile(path, []byte("first\nsecond\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutated := false
|
||||
_, _, err := scanHeadSnapshot("", path, defaultHeadLimit, func(_ []byte, _ bool) bool {
|
||||
if !mutated {
|
||||
mutated = true
|
||||
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
if _, writeErr := file.WriteString("replacement\n"); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during discovery") {
|
||||
t.Fatalf("scanHeadSnapshot error = %v, want mutation rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env is the environment agentsessions resolves store roots against. Tests
|
||||
// supply a synthetic one; production uses OSEnv. Keeping this behind a struct
|
||||
// means no test has to mutate os.Setenv (which the repo's own suite is
|
||||
// sensitive to) to exercise the redirect variables.
|
||||
type Env struct {
|
||||
Home string
|
||||
// Getenv resolves the redirect variables below. Nil behaves as "unset".
|
||||
Getenv func(string) string
|
||||
}
|
||||
|
||||
// OSEnv is the real environment.
|
||||
func OSEnv() Env {
|
||||
home, _ := os.UserHomeDir()
|
||||
return Env{Home: home, Getenv: os.Getenv}
|
||||
}
|
||||
|
||||
func (env Env) lookup(name string) string {
|
||||
if env.Getenv == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(env.Getenv(name))
|
||||
}
|
||||
|
||||
// underHome joins parts onto the home directory, returning "" when home is
|
||||
// unknown so callers degrade to "no store" rather than probing a relative path
|
||||
// off the process working directory.
|
||||
func (env Env) underHome(parts ...string) string {
|
||||
home := strings.TrimSpace(env.Home)
|
||||
if home == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(append([]string{home}, parts...)...)
|
||||
}
|
||||
|
||||
// Each of these honours the agent's documented redirect variable rather than
|
||||
// hardcoding ~. A user with CLAUDE_CONFIG_DIR or CODEX_HOME set has their
|
||||
// transcripts somewhere else entirely, and hardcoding would silently discover
|
||||
// nothing while looking like it worked.
|
||||
|
||||
func claudeCodeRoot(env Env) string {
|
||||
if dir := env.lookup("CLAUDE_CONFIG_DIR"); filepath.IsAbs(dir) {
|
||||
return filepath.Join(dir, "projects")
|
||||
}
|
||||
return env.underHome(".claude", "projects")
|
||||
}
|
||||
|
||||
func codexRoot(env Env) string {
|
||||
if dir := env.lookup("CODEX_HOME"); filepath.IsAbs(dir) {
|
||||
return filepath.Join(dir, "sessions")
|
||||
}
|
||||
return env.underHome(".codex", "sessions")
|
||||
}
|
||||
|
||||
func factoryRoot(env Env) string {
|
||||
return env.underHome(".factory", "sessions")
|
||||
}
|
||||
|
||||
// piRoot is the one to be careful with: ~/.pi/agent/auth.json is the direct
|
||||
// sibling of ~/.pi/agent/sessions/. Rooting discovery at ~/.pi/agent instead of
|
||||
// ~/.pi/agent/sessions would put a live credential file one glob away.
|
||||
func piRoot(env Env) string {
|
||||
return env.underHome(".pi", "agent", "sessions")
|
||||
}
|
||||
|
||||
// transcriptExt is the only file extension this package will open. Every
|
||||
// credential file found in these trees (auth.json, oauth_creds.json,
|
||||
// .credentials.json, auth.v2.key, config.json) ends in something else, so
|
||||
// pinning the extension is a cheap, total defence — but it is a backstop, not
|
||||
// the primary one. The primary defence is that the globs below are fixed-depth
|
||||
// and rooted at a sessions directory.
|
||||
const transcriptExt = ".jsonl"
|
||||
|
||||
// globTranscripts expands a fixed-depth glob and returns only the results that
|
||||
// are genuinely safe to open.
|
||||
//
|
||||
// filepath.Glob never recurses, so the shape of the pattern already bounds what
|
||||
// can match. Two further filters matter:
|
||||
//
|
||||
// - the extension must be exactly .jsonl, so no credential file can match even
|
||||
// if an agent later drops one into a sessions directory;
|
||||
// - the entry must be a REGULAR file by Lstat, which rejects symlinks. Without
|
||||
// this, a symlink named transcript.jsonl pointing at ~/.codex/auth.json would
|
||||
// satisfy both the pattern and the extension check and be read as a
|
||||
// transcript. Lstat does not follow the link, so the symlink is seen for
|
||||
// what it is.
|
||||
//
|
||||
// A malformed pattern or an unreadable directory yields no results rather than
|
||||
// an error: discovery is fail-soft by design (see Adapter).
|
||||
func globTranscripts(root string, pattern string) []string {
|
||||
if strings.TrimSpace(root) == "" || strings.TrimSpace(pattern) == "" {
|
||||
return nil
|
||||
}
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
safe := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
if !strings.EqualFold(filepath.Ext(match), transcriptExt) {
|
||||
continue
|
||||
}
|
||||
if pathHasSymlink(root, match) {
|
||||
continue
|
||||
}
|
||||
info, err := os.Lstat(match)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
safe = append(safe, match)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// pathHasSymlink rejects a match when any component beneath the trusted store
|
||||
// root is a symlink. The eventual read also goes through os.Root (openContained),
|
||||
// which binds containment at open time and closes the check/open race; this
|
||||
// discovery-time check prevents a symlinked project/date directory from being
|
||||
// indexed in the first place. On Windows, Lstat does not identify junctions as
|
||||
// ModeSymlink; this function is therefore only a best-effort index filter there.
|
||||
// The os.Root read is the containment boundary on every platform.
|
||||
func pathHasSymlink(root string, match string) bool {
|
||||
relative, err := filepath.Rel(root, match)
|
||||
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
current := root
|
||||
for _, component := range strings.Split(relative, string(filepath.Separator)) {
|
||||
if component == "" || component == "." {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// globSessionDirs lists the immediate subdirectories of root — one fixed level,
|
||||
// never a walk. Used when no slug candidate matches and every project directory
|
||||
// has to be considered.
|
||||
func globSessionDirs(root string) []string {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(root, "*"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dirs := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
// Lstat, not Stat: a symlinked project directory could point anywhere,
|
||||
// including a tree of credentials, and following it would put arbitrary
|
||||
// paths one glob below a directory we do trust.
|
||||
info, err := os.Lstat(match)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
dirs = append(dirs, match)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// slugCandidates returns the directory names an agent might have used for cwd.
|
||||
//
|
||||
// These agents name a directory after the working directory by replacing path
|
||||
// separators with hyphens. That mapping is LOSSY and cannot be reversed:
|
||||
// "-Users-kratos-dev-zero" is what both /Users/kratos/dev/zero and
|
||||
// /Users/kratos/dev-zero produce. It is therefore used only to NARROW the
|
||||
// search — a hit means "look here first", never "this is the session's cwd".
|
||||
// The cwd is always taken from the record body instead (see jsonl.go), and
|
||||
// callers fall back to scanning every slug directory when no candidate matches.
|
||||
//
|
||||
// Different agents mangle more than just the separator (some also fold "." and
|
||||
// "_"), and none of them document it. Rather than guess at a scheme that may
|
||||
// change, this returns several plausible spellings and treats a miss as a cache
|
||||
// miss rather than an error.
|
||||
func slugCandidates(cwd string) []string {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(cwd))
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return nil
|
||||
}
|
||||
separatorOnly := strings.ReplaceAll(cleaned, string(filepath.Separator), "-")
|
||||
folded := strings.NewReplacer(
|
||||
string(filepath.Separator), "-",
|
||||
".", "-",
|
||||
"_", "-",
|
||||
).Replace(cleaned)
|
||||
|
||||
candidates := []string{separatorOnly}
|
||||
if folded != separatorOnly {
|
||||
candidates = append(candidates, folded)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// normalizeDir resolves a directory to a stable form for comparison, following
|
||||
// symlinks so that /tmp and /private/tmp (or any other symlinked prefix) do not
|
||||
// read as different workspaces. A path that cannot be resolved — typically
|
||||
// because the directory has since been deleted, which is common in old
|
||||
// transcripts — degrades to a lexical clean rather than dropping the session.
|
||||
func normalizeDir(path string) string {
|
||||
return normalizeDirForOS(path, runtime.GOOS)
|
||||
}
|
||||
|
||||
func normalizeDirForOS(path string, goos string) string {
|
||||
return normalizeDirWithFS(path, goos, os.Stat, filepath.EvalSymlinks)
|
||||
}
|
||||
|
||||
func normalizeDirWithFS(
|
||||
path string,
|
||||
goos string,
|
||||
stat func(string) (os.FileInfo, error),
|
||||
evalSymlinks func(string) (string, error),
|
||||
) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(trimmed)
|
||||
// A foreign transcript controls this value. Windows path resolution can dial
|
||||
// UNC shares (and mapped drives) while merely building /resume, so discovery
|
||||
// never performs filesystem I/O for Windows paths. Exact lexical matching is
|
||||
// still useful and case-folded below; any stronger containment decision is
|
||||
// deferred to the rooted open used when the transcript is actually read.
|
||||
if goos == "windows" {
|
||||
return cleaned
|
||||
}
|
||||
// EvalSymlinks is useful for local aliases such as /tmp -> /private/tmp, but
|
||||
// only after a local stat proves the path currently exists. Missing or stale
|
||||
// transcript paths stay lexical and cannot trigger resolver-specific probes.
|
||||
if _, err := stat(cleaned); err != nil {
|
||||
return cleaned
|
||||
}
|
||||
resolved, err := evalSymlinks(cleaned)
|
||||
if err != nil {
|
||||
return cleaned
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// sameDir reports whether two directory paths refer to the same workspace.
|
||||
func sameDir(left string, right string) bool {
|
||||
return sameDirForOS(left, right, runtime.GOOS)
|
||||
}
|
||||
|
||||
func sameDirForOS(left string, right string, goos string) bool {
|
||||
return sameDirWithFS(left, right, goos, os.Stat, filepath.EvalSymlinks)
|
||||
}
|
||||
|
||||
// sameDirWithFS is the comparison with its filesystem access injectable, so a
|
||||
// test can prove that a Windows path is compared WITHOUT touching the resolver.
|
||||
func sameDirWithFS(
|
||||
left string,
|
||||
right string,
|
||||
goos string,
|
||||
stat func(string) (os.FileInfo, error),
|
||||
evalSymlinks func(string) (string, error),
|
||||
) bool {
|
||||
normalizedLeft := normalizeDirWithFS(left, goos, stat, evalSymlinks)
|
||||
normalizedRight := normalizeDirWithFS(right, goos, stat, evalSymlinks)
|
||||
if normalizedLeft == "" || normalizedRight == "" {
|
||||
return false
|
||||
}
|
||||
if goos == "windows" {
|
||||
return strings.EqualFold(normalizedLeft, normalizedRight)
|
||||
}
|
||||
return normalizedLeft == normalizedRight
|
||||
}
|
||||
|
||||
// SameWorkspace reports whether two workspace paths name the same directory,
|
||||
// under the policy discovery already uses for foreign paths.
|
||||
//
|
||||
// EXPORTED BECAUSE THE TUI WAS COMPARING WITH A DIFFERENT POLICY. Discovery
|
||||
// deliberately never resolves a Windows path (normalizeDirWithFS): a transcript
|
||||
// controls that value, and EvalSymlinks on "\\server\share\repo" dials the
|
||||
// share — SMB authentication and a stalled UI on an unavailable host — merely
|
||||
// to decide whether a row belongs in /resume. The TUI's own comparison then
|
||||
// reintroduced exactly that call, for the same foreign value, on the Update
|
||||
// loop: once while formatting the post-import note, and again for every
|
||||
// persisted WorkspaceKey while filtering the picker and choosing the latest
|
||||
// session. Checking runtime.GOOS after the resolver had already run could not
|
||||
// prevent the effect. One comparison, one policy: lexical and case-insensitive
|
||||
// on Windows, stat-then-resolve elsewhere so local aliases such as
|
||||
// /tmp -> /private/tmp still match. Reported by @jatmn.
|
||||
func SameWorkspace(left string, right string) bool {
|
||||
return sameDir(left, right)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testEnv(home string, vars map[string]string) Env {
|
||||
return Env{Home: home, Getenv: func(name string) string { return vars[name] }}
|
||||
}
|
||||
|
||||
func TestRootsHonourTheAgentsRedirectVariables(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
|
||||
plain := testEnv(home, nil)
|
||||
if got, want := claudeCodeRoot(plain), filepath.Join(home, ".claude", "projects"); got != want {
|
||||
t.Errorf("claudeCodeRoot = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := codexRoot(plain), filepath.Join(home, ".codex", "sessions"); got != want {
|
||||
t.Errorf("codexRoot = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// A user with these set keeps their transcripts somewhere else entirely.
|
||||
// Hardcoding ~ would discover nothing while looking like it worked.
|
||||
redirected := testEnv(home, map[string]string{
|
||||
"CLAUDE_CONFIG_DIR": elsewhere,
|
||||
"CODEX_HOME": elsewhere,
|
||||
})
|
||||
if got, want := claudeCodeRoot(redirected), filepath.Join(elsewhere, "projects"); got != want {
|
||||
t.Errorf("CLAUDE_CONFIG_DIR ignored: got %q, want %q", got, want)
|
||||
}
|
||||
if got, want := codexRoot(redirected), filepath.Join(elsewhere, "sessions"); got != want {
|
||||
t.Errorf("CODEX_HOME ignored: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeRedirectVariablesAreRejected(t *testing.T) {
|
||||
env := testEnv(t.TempDir(), map[string]string{
|
||||
"CLAUDE_CONFIG_DIR": ".config/claude",
|
||||
"CODEX_HOME": ".codex",
|
||||
})
|
||||
if got, want := claudeCodeRoot(env), filepath.Join(env.Home, ".claude", "projects"); got != want {
|
||||
t.Fatalf("relative CLAUDE_CONFIG_DIR resolved from process cwd: got %q, want %q", got, want)
|
||||
}
|
||||
if got, want := codexRoot(env), filepath.Join(env.Home, ".codex", "sessions"); got != want {
|
||||
t.Fatalf("relative CODEX_HOME resolved from process cwd: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownHomeYieldsNoRootRatherThanARelativePath(t *testing.T) {
|
||||
// With no home, a naive filepath.Join would produce ".claude/projects" and
|
||||
// probe relative to the process working directory — a different user's
|
||||
// checkout, in the worst case. Every root must come back empty instead.
|
||||
blank := Env{}
|
||||
for name, root := range map[string]string{
|
||||
"claude": claudeCodeRoot(blank),
|
||||
"codex": codexRoot(blank),
|
||||
"factory": factoryRoot(blank),
|
||||
"pi": piRoot(blank),
|
||||
} {
|
||||
if root != "" {
|
||||
t.Errorf("%s root with no home = %q, want empty", name, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// credentialFilenames are the real filenames found beside real transcripts on a
|
||||
// working machine. Every one of the seven agents surveyed keeps at least one of
|
||||
// these in the same tree as its sessions.
|
||||
var credentialFilenames = []string{
|
||||
"auth.json",
|
||||
"auth.v2.key",
|
||||
"auth.v2.file",
|
||||
".credentials.json",
|
||||
"oauth_creds.json",
|
||||
"config.json",
|
||||
"credentials",
|
||||
}
|
||||
|
||||
// TestDiscoveryGlobsNeverMatchACredentialFile is the reason this package uses
|
||||
// fixed-depth globs instead of filepath.WalkDir.
|
||||
//
|
||||
// The layout below mirrors ~/.pi/agent/, where auth.json is the direct sibling
|
||||
// of sessions/ — the tightest real case. If a future change swaps the glob for
|
||||
// a walk, or roots it one directory higher, this test fails.
|
||||
func TestDiscoveryGlobsNeverMatchACredentialFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
agentDir := filepath.Join(home, ".pi", "agent")
|
||||
sessionsDir := filepath.Join(agentDir, "sessions", "-Users-someone-proj")
|
||||
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A real transcript, which must be found.
|
||||
transcript := filepath.Join(sessionsDir, "2026-01-01T00-00-00Z_abc.jsonl")
|
||||
writeFile(t, transcript, `{"type":"session","cwd":"/Users/someone/proj"}`)
|
||||
|
||||
// Decoy transcripts at depths the fixed-depth glob must not reach. These
|
||||
// pin the MECHANISM rather than the outcome: the credential filenames below
|
||||
// are already caught by the extension check, so without these a directory
|
||||
// walk substituted for the glob would still pass. Each of these is a
|
||||
// well-formed .jsonl that only a walk (or a wrong root) can find.
|
||||
offDepth := []string{
|
||||
filepath.Join(agentDir, "sessions", "stray.jsonl"), // too shallow
|
||||
filepath.Join(sessionsDir, "nested", "deep.jsonl"), // too deep
|
||||
filepath.Join(sessionsDir, "nested", "deeper", "deepest.jsonl"), // deeper still
|
||||
filepath.Join(agentDir, "leaked.jsonl"), // above the sessions root
|
||||
filepath.Join(agentDir, "checkpoints", "proj", "checkpoint.jsonl"), // sibling tree
|
||||
}
|
||||
for _, path := range offDepth {
|
||||
writeFile(t, path, `{"type":"session","cwd":"/Users/someone/proj"}`)
|
||||
}
|
||||
|
||||
// Credentials planted at every level a careless root or a walk would reach:
|
||||
// beside the transcript, in the sessions root, and — the ~/.pi/agent case —
|
||||
// one level above the sessions directory.
|
||||
planted := []string{}
|
||||
for _, dir := range []string{sessionsDir, filepath.Join(agentDir, "sessions"), agentDir} {
|
||||
for _, name := range credentialFilenames {
|
||||
path := filepath.Join(dir, name)
|
||||
writeFile(t, path, `{"access_token":"tok_MUST_NEVER_BE_READ"}`)
|
||||
planted = append(planted, path)
|
||||
}
|
||||
}
|
||||
|
||||
env := testEnv(home, nil)
|
||||
matches := globTranscripts(piRoot(env), filepath.Join(piRoot(env), "*", "*"+transcriptExt))
|
||||
|
||||
if len(matches) != 1 || matches[0] != transcript {
|
||||
t.Fatalf("glob = %v, want exactly [%s] — anything extra means discovery "+
|
||||
"is reaching beyond one fixed depth below the sessions root", matches, transcript)
|
||||
}
|
||||
for _, got := range matches {
|
||||
for _, path := range offDepth {
|
||||
if got == path {
|
||||
t.Errorf("discovery reached an off-depth file (a walk, not a glob): %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Belt and braces: neither the exact planted paths nor any file merely
|
||||
// *named* like a credential may appear, so a future glob that reaches a
|
||||
// different directory still fails here.
|
||||
for _, got := range matches {
|
||||
for _, path := range planted {
|
||||
if got == path {
|
||||
t.Errorf("glob matched a credential file: %s", got)
|
||||
}
|
||||
}
|
||||
for _, name := range credentialFilenames {
|
||||
if strings.EqualFold(filepath.Base(got), name) {
|
||||
t.Errorf("glob matched a credential filename: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGlobRejectsASymlinkWearingATranscriptExtension closes the gap the
|
||||
// extension check alone leaves open: the name says .jsonl, the target is a
|
||||
// credential file. Lstat does not follow the link, so the entry is seen for
|
||||
// what it is and dropped.
|
||||
func TestGlobRejectsASymlinkWearingATranscriptExtension(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs elevation on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "-Users-someone-proj")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := filepath.Join(root, "auth.json")
|
||||
writeFile(t, secret, `{"access_token":"tok_MUST_NEVER_BE_READ"}`)
|
||||
|
||||
disguised := filepath.Join(dir, "innocent.jsonl")
|
||||
if err := os.Symlink(secret, disguised); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
real := filepath.Join(dir, "real.jsonl")
|
||||
writeFile(t, real, `{"type":"session"}`)
|
||||
|
||||
matches := globTranscripts(root, filepath.Join(root, "*", "*"+transcriptExt))
|
||||
if len(matches) != 1 || matches[0] != real {
|
||||
t.Fatalf("glob = %v, want exactly [%s] — the symlink must be rejected", matches, real)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobRejectsTranscriptBelowSymlinkedDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs elevation on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outsideTranscript := filepath.Join(outside, "escaped.jsonl")
|
||||
writeFile(t, outsideTranscript, `{"type":"session"}`)
|
||||
linkedDir := filepath.Join(root, "project")
|
||||
if err := os.Symlink(outside, linkedDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
matches := globTranscripts(root, filepath.Join(root, "*", "*"+transcriptExt))
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("glob followed a symlinked directory outside the store: %v", matches)
|
||||
}
|
||||
if _, err := openContained(root, filepath.Join(linkedDir, "escaped.jsonl")); err == nil {
|
||||
t.Fatal("rooted open followed a symlink outside the store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobDegradesToEmptyRatherThanFailing(t *testing.T) {
|
||||
// A store that was never created, and a pattern that cannot compile. Both
|
||||
// mean "this adapter has nothing", never an error that fails the command
|
||||
// for the other six agents.
|
||||
root := t.TempDir()
|
||||
if got := globTranscripts(root, filepath.Join(root, "absent", "*", "*.jsonl")); len(got) != 0 {
|
||||
t.Errorf("missing root = %v, want empty", got)
|
||||
}
|
||||
if got := globTranscripts(root, filepath.Join(root, "[", "*.jsonl")); len(got) != 0 {
|
||||
t.Errorf("malformed pattern = %v, want empty", got)
|
||||
}
|
||||
if got := globTranscripts("", ""); len(got) != 0 {
|
||||
t.Errorf("empty pattern = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobIgnoresNonTranscriptExtensions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "proj")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"a.json", "b.db", "c.jsonl.bak", "d.txt", "keep.jsonl"} {
|
||||
writeFile(t, filepath.Join(dir, name), "{}")
|
||||
}
|
||||
matches := globTranscripts(root, filepath.Join(root, "*", "*"))
|
||||
if len(matches) != 1 || filepath.Base(matches[0]) != "keep.jsonl" {
|
||||
t.Fatalf("glob = %v, want only keep.jsonl", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugCandidatesNarrowButAreNeverAuthoritative(t *testing.T) {
|
||||
got := slugCandidates("/Users/kratos/dev/zero")
|
||||
if len(got) == 0 || got[0] != "-Users-kratos-dev-zero" {
|
||||
t.Fatalf("slugCandidates = %v, want the separator-folded form first", got)
|
||||
}
|
||||
|
||||
// The lossiness this guards against: two different directories produce the
|
||||
// same slug, which is why the cwd is always re-read from the record body.
|
||||
if slugCandidates("/Users/kratos/dev/zero")[0] != slugCandidates("/Users/kratos/dev-zero")[0] {
|
||||
t.Fatal("expected these two cwds to collide — if they no longer do, the " +
|
||||
"comment in paths.go about slug lossiness needs revisiting")
|
||||
}
|
||||
|
||||
if got := slugCandidates(""); got != nil {
|
||||
t.Errorf("slugCandidates(\"\") = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameDirFollowsSymlinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
real := filepath.Join(root, "real")
|
||||
if err := os.MkdirAll(real, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameDir(real, real+string(filepath.Separator)+".") {
|
||||
t.Error("a path and its own clean form should match")
|
||||
}
|
||||
if sameDir(real, filepath.Join(root, "other")) {
|
||||
t.Error("different directories should not match")
|
||||
}
|
||||
if sameDir("", real) || sameDir(real, "") {
|
||||
t.Error("an empty path should never match anything")
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
link := filepath.Join(root, "link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameDir(link, real) {
|
||||
t.Error("a symlinked workspace should resolve to the same directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameDirUsesCaseInsensitiveComparisonOnWindows(t *testing.T) {
|
||||
if !sameDirForOS("/Work/Project", "/work/project", "windows") {
|
||||
t.Fatal("Windows workspace comparison rejected a case-only spelling difference")
|
||||
}
|
||||
if sameDirForOS("/Work/Project", "/work/other", "windows") {
|
||||
t.Fatal("Windows workspace comparison accepted different paths")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsForeignPathsNeverTouchTheFilesystemDuringNormalization(t *testing.T) {
|
||||
path := `\\192.0.2.201\share\loot`
|
||||
called := false
|
||||
got := normalizeDirWithFS(path, "windows",
|
||||
func(string) (os.FileInfo, error) {
|
||||
called = true
|
||||
return nil, errors.New("must not stat a foreign Windows path")
|
||||
},
|
||||
func(string) (string, error) {
|
||||
called = true
|
||||
return "", errors.New("must not resolve a foreign Windows path")
|
||||
},
|
||||
)
|
||||
if called {
|
||||
t.Fatal("Windows path normalization performed filesystem I/O")
|
||||
}
|
||||
if got != filepath.Clean(path) {
|
||||
t.Fatalf("normalizeDirWithFS = %q, want lexical %q", got, filepath.Clean(path))
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fileSizeOf(t *testing.T, path string) int64 {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func itoa(value int) string { return strconv.Itoa(value) }
|
||||
|
||||
// COMPARING A FOREIGN WINDOWS PATH MUST NOT TOUCH THE FILESYSTEM. A transcript
|
||||
// controls its cwd, and resolving "\\server\share\repo" on Windows dials the
|
||||
// share. Discovery already compares such paths lexically; the TUI's picker,
|
||||
// latest-session filter and post-import note now go through the same helper,
|
||||
// so this is the one place that policy is proved. The resolver seams here fail
|
||||
// the test if they are ever called for a Windows path.
|
||||
func TestAForeignWindowsPathIsComparedWithoutResolverIO(t *testing.T) {
|
||||
fatalStat := func(path string) (os.FileInfo, error) {
|
||||
t.Fatalf("stat was called for a foreign Windows path: %q", path)
|
||||
return nil, nil
|
||||
}
|
||||
fatalEval := func(path string) (string, error) {
|
||||
t.Fatalf("EvalSymlinks was called for a foreign Windows path: %q", path)
|
||||
return "", nil
|
||||
}
|
||||
if !sameDirWithFS(`\\server\share\repo`, `\\SERVER\share\REPO`, "windows", fatalStat, fatalEval) {
|
||||
t.Error("case-insensitive UNC match failed")
|
||||
}
|
||||
if sameDirWithFS(`\\server\share\repo`, `\\server\share\other`, "windows", fatalStat, fatalEval) {
|
||||
t.Error("different UNC paths compared equal")
|
||||
}
|
||||
if !sameDirWithFS(`C:\Proj\repo`, `c:\proj\REPO`, "windows", fatalStat, fatalEval) {
|
||||
t.Error("case-insensitive drive path match failed")
|
||||
}
|
||||
// Elsewhere a local alias still resolves — after a stat proves it exists —
|
||||
// so /tmp -> /private/tmp keeps matching for the TUI.
|
||||
if runtime.GOOS != "windows" {
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if !SameWorkspace(link, target) {
|
||||
t.Errorf("a symlinked local workspace no longer matches its target")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// pi reads Pi's agent transcripts: ~/.pi/agent/sessions/<slug>/<ts>_<uuid>.jsonl.
|
||||
//
|
||||
// PI SHARES FAMILY 1'S DIRECTORY LAYOUT AND NOT ITS MESSAGE SCHEMA. It was
|
||||
// routed through the Claude parser on the strength of the layout, and the
|
||||
// parser's block vocabulary (tool_use / tool_result, is_error, snake_case ids)
|
||||
// is simply not what Pi writes:
|
||||
//
|
||||
// header {"type":"session","id":…,"timestamp":…,"cwd":…}
|
||||
// entry {"type":"message","id":…,"parentId":…,"timestamp":…,"message":{…}}
|
||||
// user {"role":"user","content":"…" | [{"type":"text","text":…}]}
|
||||
// assistant{"role":"assistant","model":…,"content":[{"type":"text"|"thinking"|"toolCall",…}]}
|
||||
// toolCall {"type":"toolCall","id":…,"name":…,"arguments":{…object…}}
|
||||
// result {"role":"toolResult","toolCallId":…,"toolName":…,"isError":bool,"content":[{"type":"text",…}]}
|
||||
//
|
||||
// The shared parser recognized none of the tool records and rejected the
|
||||
// toolResult role outright, so a Pi session containing a request and a completed
|
||||
// tool call imported "successfully" with no call, no result and no activity
|
||||
// summary — the work needed to continue it silently omitted — and the outer
|
||||
// type of "message" on every entry meant the title check for type "user" never
|
||||
// fired, so every Pi session was "untitled". Both were one wrong assumption.
|
||||
// Reported by @jatmn.
|
||||
//
|
||||
// Bounded reading, event construction, redaction, the reasoning opt-in and the
|
||||
// activity summary stay shared; only the vendor's parsing differs.
|
||||
type pi struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// Pi reads Pi's agent transcripts.
|
||||
func Pi(env Env) Adapter {
|
||||
return pi{root: piRoot(env)}
|
||||
}
|
||||
|
||||
func (adapter pi) Name() string { return "pi" }
|
||||
|
||||
func (adapter pi) Discover(cwd string) ([]ForeignSession, error) {
|
||||
return discoverFamily1(adapter.Name(), adapter.root, cwd, indexPiTranscript)
|
||||
}
|
||||
|
||||
func (adapter pi) Read(source ForeignSession, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
if source.Agent != adapter.Name() || source.ID != transcriptID(source.Path) {
|
||||
return nil, errNotThisAgent(adapter.Name())
|
||||
}
|
||||
// One handle from identity check to final check: see openSelectedSource.
|
||||
file, err := openSelectedSource(adapter.root, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
events, err := translatePi(file, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSourceHandle(file, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// piEntry is one line of a Pi session file: the session header or an entry.
|
||||
// Unknown entry types (thinking_level_change, model_change, compaction, …)
|
||||
// carry no message and drop out naturally.
|
||||
type piEntry struct {
|
||||
Type string `json:"type"`
|
||||
Cwd string `json:"cwd"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Message *piMessage `json:"message"`
|
||||
}
|
||||
|
||||
type piMessage struct {
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
// toolResult fields.
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"toolName"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
|
||||
type piBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
// indexPiTranscript builds an index entry from a bounded read of the head. The
|
||||
// workspace and start time come from the session header; the title is the
|
||||
// first user prompt, which is inside a "message" entry, not a "user" record.
|
||||
func indexPiTranscript(agent string, root string, path string) (ForeignSession, bool) {
|
||||
session := ForeignSession{Agent: agent, ID: transcriptID(path), Path: path}
|
||||
firstPrompt := ""
|
||||
_, snapshot, err := scanHeadSnapshot(root, path, defaultHeadLimit, func(line []byte, truncated bool) bool {
|
||||
var entry piEntry
|
||||
if json.Unmarshal(line, &entry) != nil {
|
||||
if truncated {
|
||||
recovered := topLevelStrings(line, "cwd", "timestamp")
|
||||
if session.Cwd == "" {
|
||||
session.Cwd = recovered["cwd"]
|
||||
}
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = parseTimestamp(recovered["timestamp"])
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if session.Cwd == "" {
|
||||
session.Cwd = entry.Cwd
|
||||
}
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = parseTimestamp(entry.Timestamp)
|
||||
}
|
||||
if entry.Type != "message" || entry.Message == nil {
|
||||
return true
|
||||
}
|
||||
if session.ModelID == "" && entry.Message.Role == "assistant" {
|
||||
session.ModelID = entry.Message.Model
|
||||
}
|
||||
if firstPrompt == "" && entry.Message.Role == "user" {
|
||||
firstPrompt = piBlocksText(entry.Message.Content)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return ForeignSession{}, false
|
||||
}
|
||||
session.Title = summarizeTitle(firstPrompt)
|
||||
if strings.TrimSpace(session.Cwd) == "" {
|
||||
return ForeignSession{}, false
|
||||
}
|
||||
session.source = snapshot
|
||||
session.UpdatedAt = snapshot.modTime
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = session.UpdatedAt
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
// translatePi converts a Pi session into Zero events. Same lossy-in-one-direction
|
||||
// policy as translateFamily1: the conversation and the tool work are kept, the
|
||||
// provider's private machinery (signatures, usage, diagnostics) is dropped.
|
||||
func translatePi(file readSeekStater, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
events := newEventTail(effectiveMaxEvents(options.MaxEvents))
|
||||
identities := &importCallIdentities{}
|
||||
activity := newActivityLog(options.Cwd)
|
||||
omitted := 0
|
||||
prefixOmitted, err := streamTailLines(file, importLineLimit, importByteLimit, func(line []byte, truncated bool) bool {
|
||||
if truncated {
|
||||
omitted++
|
||||
return true
|
||||
}
|
||||
var entry piEntry
|
||||
if json.Unmarshal(line, &entry) != nil || entry.Type != "message" || entry.Message == nil {
|
||||
return true
|
||||
}
|
||||
message := entry.Message
|
||||
switch strings.ToLower(strings.TrimSpace(message.Role)) {
|
||||
case "user":
|
||||
if text := piBlocksText(message.Content); strings.TrimSpace(text) != "" {
|
||||
events.add(messageEvent("user", text))
|
||||
}
|
||||
case "assistant":
|
||||
var blocks []piBlock
|
||||
if json.Unmarshal(message.Content, &blocks) != nil {
|
||||
return true
|
||||
}
|
||||
for _, block := range blocks {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
if strings.TrimSpace(block.Text) != "" {
|
||||
events.add(messageEvent("assistant", block.Text))
|
||||
}
|
||||
case "thinking":
|
||||
if options.IncludeReasoning && strings.TrimSpace(block.Thinking) != "" {
|
||||
events.add(messageEvent("reasoning", block.Thinking))
|
||||
}
|
||||
case "toolCall":
|
||||
arguments := string(block.Arguments)
|
||||
activity.observeCall(block.ID, block.Name, arguments)
|
||||
events.add(toolCallEvent(identities, block.Name, block.ID, arguments))
|
||||
}
|
||||
}
|
||||
case "toolresult":
|
||||
// Pi records the outcome explicitly, so it is carried as fact: only a
|
||||
// confirmed success commits an activity claim (observeResult).
|
||||
status := tools.StatusOK
|
||||
if message.IsError {
|
||||
status = tools.StatusError
|
||||
}
|
||||
name := strings.TrimSpace(message.ToolName)
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
output := piBlocksText(message.Content)
|
||||
activity.observeResult(message.ToolCallID, name, status, output)
|
||||
events.add(toolResultEvent(identities, name, message.ToolCallID, status, output))
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contextEvents := activity.summaryEvents()
|
||||
if prefixOmitted {
|
||||
contextEvents = append(contextEvents, omittedPrefixEvent())
|
||||
}
|
||||
if omitted > 0 {
|
||||
contextEvents = append(contextEvents, omittedRecordsEvent(omitted))
|
||||
}
|
||||
return capTranslatedEventsDropped(events.values(), contextEvents, effectiveMaxEvents(options.MaxEvents), events.dropped), nil
|
||||
}
|
||||
|
||||
// piBlocksText flattens Pi content, which is a bare string or an array of
|
||||
// blocks, to its text. Images and tool calls contribute nothing here.
|
||||
func piBlocksText(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
return text
|
||||
}
|
||||
var blocks []piBlock
|
||||
if json.Unmarshal(raw, &blocks) != nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
for _, block := range blocks {
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
parts = append(parts, block.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A source-shaped Pi session: the real outer wrapper (a "session" header, then
|
||||
// "message" entries carrying id/parentId), a visible user prompt, an assistant
|
||||
// toolCall with object arguments, a matching toolResult with isError false, a
|
||||
// second call whose result reports isError true, and an unrelated entry type.
|
||||
func writePiSession(t *testing.T, home string) string {
|
||||
t.Helper()
|
||||
const ts = "2026-09-01T10:00:00.000Z"
|
||||
lines := []string{
|
||||
`{"type":"session","version":3,"id":"abc","timestamp":"` + ts + `","cwd":"/w"}`,
|
||||
`{"type":"message","id":"m1","parentId":null,"timestamp":"` + ts + `","message":{"role":"user","content":"Fix the parser","timestamp":1}}`,
|
||||
`{"type":"message","id":"m2","parentId":"m1","timestamp":"` + ts + `","message":{"role":"assistant","model":"gpt-x","api":"a","provider":"p","content":[{"type":"thinking","thinking":"private chain"},{"type":"text","text":"Reading it."},{"type":"toolCall","id":"tc1","name":"read","arguments":{"path":"parser.go"}}],"stopReason":"toolUse","timestamp":2}}`,
|
||||
`{"type":"message","id":"m3","parentId":"m2","timestamp":"` + ts + `","message":{"role":"toolResult","toolCallId":"tc1","toolName":"read","content":[{"type":"text","text":"func parse() {}"}],"isError":false,"timestamp":3}}`,
|
||||
`{"type":"message","id":"m4","parentId":"m3","timestamp":"` + ts + `","message":{"role":"assistant","model":"gpt-x","content":[{"type":"toolCall","id":"tc2","name":"write","arguments":{"path":"parser.go","content":"new"}}],"stopReason":"toolUse","timestamp":4}}`,
|
||||
`{"type":"message","id":"m5","parentId":"m4","timestamp":"` + ts + `","message":{"role":"toolResult","toolCallId":"tc2","toolName":"write","content":[{"type":"text","text":"permission denied"}],"isError":true,"timestamp":5}}`,
|
||||
`{"type":"message","id":"m6","parentId":"m5","timestamp":"` + ts + `","message":{"role":"assistant","model":"gpt-x","content":[{"type":"text","text":"The write failed."}],"stopReason":"stop","timestamp":6}}`,
|
||||
`{"type":"model_change","id":"m7","parentId":"m6","timestamp":"` + ts + `","provider":"p","modelId":"gpt-y"}`,
|
||||
}
|
||||
path := filepath.Join(home, ".pi", "agent", "sessions", "-w", "2026-09-01T10-00-00_abc.jsonl")
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
return path
|
||||
}
|
||||
|
||||
// PI'S SCHEMA IS ITS OWN, AND ITS TOOL WORK HAS TO SURVIVE TRANSLATION. Routed
|
||||
// through the Claude parser, a Pi session with a request and a completed tool
|
||||
// call imported "successfully" with no call, no result and no activity summary,
|
||||
// and every Pi session was "untitled" because the title check looked for an
|
||||
// outer type of "user" that Pi never writes. This pins the vendor's real shapes
|
||||
// end to end: index, import, stored pairing and outcome, activity claims, and
|
||||
// the resume digest.
|
||||
func TestPiSessionsImportTheirRealToolSchema(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
path := writePiSession(t, home)
|
||||
adapter := Pi(testEnv(home, nil))
|
||||
|
||||
found, err := adapter.Discover("/w")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover: %v (%d results)", err, len(found))
|
||||
}
|
||||
if found[0].Title != "Fix the parser" || found[0].Cwd != "/w" || found[0].ModelID != "gpt-x" {
|
||||
t.Fatalf("index = %+v, want title from the first prompt, cwd from the header, model from the assistant", found[0])
|
||||
}
|
||||
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := Import(store, adapter, transcriptID(path), ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(result.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type call struct{ name, id string }
|
||||
type outcome struct{ status, output string }
|
||||
calls := map[string]call{}
|
||||
results := map[string]outcome{}
|
||||
var texts []string
|
||||
var summaries []string
|
||||
for _, event := range events {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get := func(key string) string { value, _ := payload[key].(string); return value }
|
||||
switch event.Type {
|
||||
case sessions.EventToolCall:
|
||||
calls[get("toolCallId")] = call{name: get("name"), id: get("toolCallId")}
|
||||
case sessions.EventToolResult:
|
||||
results[get("toolCallId")] = outcome{status: get("status"), output: get("output")}
|
||||
case sessions.EventMessage:
|
||||
if NoteEventIsSummary(payload) {
|
||||
summaries = append(summaries, get("content"))
|
||||
continue
|
||||
}
|
||||
texts = append(texts, get("role")+": "+get("content"))
|
||||
}
|
||||
}
|
||||
if len(calls) != 2 || len(results) != 2 {
|
||||
t.Fatalf("calls=%d results=%d, want both tool calls with their results: %v / %v", len(calls), len(results), calls, results)
|
||||
}
|
||||
var okRead, failedWrite bool
|
||||
for id, c := range calls {
|
||||
r, paired := results[id]
|
||||
if !paired {
|
||||
t.Fatalf("call %s (%s) has no paired result", id, c.name)
|
||||
}
|
||||
switch c.name {
|
||||
case "read":
|
||||
okRead = r.status == string(tools.StatusOK) && r.output == "func parse() {}"
|
||||
case "write":
|
||||
failedWrite = r.status == string(tools.StatusError) && r.output == "permission denied"
|
||||
}
|
||||
}
|
||||
if !okRead || !failedWrite {
|
||||
t.Fatalf("outcomes were not carried: calls=%v results=%v", calls, results)
|
||||
}
|
||||
joined := strings.Join(texts, "\n")
|
||||
for _, want := range []string{"user: Fix the parser", "assistant: Reading it.", "assistant: The write failed."} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("conversation is missing %q:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "private chain") {
|
||||
t.Errorf("reasoning was imported without being asked for:\n%s", joined)
|
||||
}
|
||||
summary := strings.Join(summaries, "\n")
|
||||
// Only the confirmed failure is a factual claim; the failed write must not
|
||||
// be reported as a change to parser.go.
|
||||
if !strings.Contains(summary, "permission denied") {
|
||||
t.Errorf("activity summary does not carry the failed call:\n%s", summary)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(summary), "changed") && strings.Contains(summary, "parser.go") {
|
||||
t.Errorf("a failed write was claimed as a change:\n%s", summary)
|
||||
}
|
||||
|
||||
// The reasoning opt-in still works for Pi's "thinking" blocks.
|
||||
withReasoning, err := adapter.Read(found[0], ReadOptions{IncludeReasoning: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sawReasoning := false
|
||||
for _, event := range withReasoning {
|
||||
payload, _ := event.Payload.(map[string]any)
|
||||
if payload["role"] == "reasoning" && strings.Contains(payload["content"].(string), "private chain") {
|
||||
sawReasoning = true
|
||||
}
|
||||
}
|
||||
if !sawReasoning {
|
||||
t.Error("IncludeReasoning did not surface Pi's thinking block")
|
||||
}
|
||||
|
||||
// And the real resume path sees the work.
|
||||
prepared, err := sessions.PrepareExec(sessions.PrepareExecOptions{Store: store, Resume: result.Session.SessionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := sessions.FormatExecPrompt("continue", prepared)
|
||||
for _, want := range []string{"Fix the parser", "The write failed.", "reference context only"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Errorf("resume prompt is missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A NORMALIZER THAT REMOVES BYTES IS ALSO A REASSEMBLER, SO IT CANNOT RUN LAST.
|
||||
//
|
||||
// redact() strips control bytes and matches secrets by shape. The order decides
|
||||
// whether it works at all: matching first lets a transcript split a credential
|
||||
// with a byte the stripper then deletes, rejoining the halves after the patterns
|
||||
// have already declined to match them. Every shape leaked that way.
|
||||
//
|
||||
// This is the same defect as #835, where an MCP failure reason was redacted
|
||||
// before the terminal sanitizer rejoined its halves, so the test is written to
|
||||
// fail loudly rather than to describe the current behaviour.
|
||||
//
|
||||
// A foreign transcript is untrusted input by construction, which is what makes
|
||||
// this worth a dedicated test: the whole feature is reading one.
|
||||
func TestASecretSplitByAControlByteIsStillRedacted(t *testing.T) {
|
||||
secrets := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
// Shapes redaction recognizes. Synthetic, and long enough to match the
|
||||
// real patterns rather than a near-miss that would pass for the wrong
|
||||
// reason.
|
||||
{name: "anthropic key", value: "sk-ant-api03-" + strings.Repeat("A", 24)},
|
||||
{name: "github pat", value: "ghp_" + strings.Repeat("B", 36)},
|
||||
{name: "aws access key", value: "AKIA" + strings.Repeat("C", 16)},
|
||||
}
|
||||
// Every byte stripControl removes, because each one rejoins the halves. Tab
|
||||
// and newline are deliberately absent: those survive stripping, so they
|
||||
// separate rather than reassemble.
|
||||
splitters := []struct {
|
||||
name string
|
||||
byte string
|
||||
}{
|
||||
{name: "NUL", byte: "\x00"},
|
||||
{name: "ESC", byte: "\x1b"},
|
||||
{name: "backspace", byte: "\x08"},
|
||||
{name: "DEL", byte: "\x7f"},
|
||||
{name: "C1 (0x85)", byte: string(rune(0x85))},
|
||||
}
|
||||
|
||||
for _, secret := range secrets {
|
||||
t.Run(secret.name, func(t *testing.T) {
|
||||
// The control arm. If redaction cannot catch the unsplit value then
|
||||
// the split cases below would pass for the wrong reason.
|
||||
if got := redact("token " + secret.value + " end"); strings.Contains(got, secret.value) {
|
||||
t.Fatalf("redaction does not recognize this shape at all, so the split cases prove nothing: %q", got)
|
||||
}
|
||||
|
||||
for _, splitter := range splitters {
|
||||
t.Run(splitter.name, func(t *testing.T) {
|
||||
// An empty splitter would make every Contains check below vacuously
|
||||
// true. Assert it rather than trust the literal survived editing.
|
||||
if splitter.byte == "" {
|
||||
t.Fatal("splitter byte is empty; the literal was lost and this case proves nothing")
|
||||
}
|
||||
half := len(secret.value) / 2
|
||||
split := secret.value[:half] + splitter.byte + secret.value[half:]
|
||||
|
||||
got := redact("token " + split + " end")
|
||||
|
||||
// The assertion is about the text a READER ends up with. The
|
||||
// splitter is gone by then either way, so checking for the
|
||||
// intact secret in the output is checking exactly what would
|
||||
// reach a picker row or a transcript line.
|
||||
if strings.Contains(got, secret.value) {
|
||||
t.Errorf("a credential split by %s was reassembled after redaction and reached the output: %q", splitter.name, got)
|
||||
}
|
||||
if strings.Contains(got, splitter.byte) {
|
||||
t.Errorf("the control byte survived into the output: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The counterpart, so the fix cannot be "strip everything and call it redaction".
|
||||
// A separator that survives stripping does NOT rejoin, and the text around a
|
||||
// secret has to come through intact either way.
|
||||
func TestRedactKeepsTheSurroundingText(t *testing.T) {
|
||||
got := redact("before sk-ant-api03-" + strings.Repeat("A", 24) + " after")
|
||||
for _, want := range []string{"before", "after"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("redaction ate the surrounding text, leaving %q", got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "[REDACTED]") {
|
||||
t.Errorf("the secret was not redacted at all: %q", got)
|
||||
}
|
||||
// A newline separates rather than rejoins, so the halves must NOT become the
|
||||
// secret, and the newline itself is legitimate transcript content.
|
||||
//
|
||||
// THE COMMENT ABOVE WAS THE ONLY THING ASSERTING THE FIRST HALF OF THAT. The
|
||||
// newline check alone passes just as well if the matcher spans the newline
|
||||
// and redacts both halves as one secret — the separator would survive inside
|
||||
// a "[REDACTED]" that ate the text around it. Both halves are named here, and
|
||||
// so is the absence of any redaction at all, because the failure this guards
|
||||
// against is over-redaction: a credential cannot contain a raw newline, so
|
||||
// treating a newline-split pair as one destroys legitimate transcript content
|
||||
// while protecting nothing. It is also what stops stripControl being widened
|
||||
// to strip newlines, which would make the NUL case above pass for the wrong
|
||||
// reason.
|
||||
halves := []string{"sk-ant-api03-", strings.Repeat("A", 24)}
|
||||
split := redact("before " + halves[0] + "\n" + halves[1] + " after")
|
||||
if !strings.Contains(split, "\n") {
|
||||
t.Errorf("a newline was stripped from transcript text: %q", split)
|
||||
}
|
||||
for _, half := range halves {
|
||||
if !strings.Contains(split, half) {
|
||||
t.Errorf("a newline-separated half %q was consumed as part of a secret, leaving %q", half, split)
|
||||
}
|
||||
}
|
||||
if strings.Contains(split, "[REDACTED]") {
|
||||
t.Errorf("two halves separated by a newline were redacted as one secret: %q", split)
|
||||
}
|
||||
}
|
||||
|
||||
// A FORMAT CHARACTER IS NOT A CONTROL CHARACTER, and unicode.IsControl agrees —
|
||||
// which is the problem. U+202E RIGHT-TO-LEFT OVERRIDE reorders everything after
|
||||
// it, so a title or a tool name can be made to render as something entirely
|
||||
// different while every byte stays innocent: "gnp.txt.exe" preceded by an
|
||||
// override reads as an image file. Category Cf is invisible by definition and
|
||||
// nothing in a transcript needs it.
|
||||
func TestABidiOverrideIsStrippedFromTitlesAndToolNames(t *testing.T) {
|
||||
for _, hidden := range []string{"\u202e", "\u200b", "\u2066", "\u2069", "\ufeff"} {
|
||||
title := "deploy " + hidden + "gnp.txt.exe"
|
||||
if got := DisplayField(title); strings.Contains(got, hidden) {
|
||||
t.Errorf("a format character %q survived DisplayField: %q", hidden, got)
|
||||
}
|
||||
toolName := "read" + hidden + "_file"
|
||||
got, _ := stripControlWithBoundaries(toolName)
|
||||
if strings.Contains(got, hidden) {
|
||||
t.Errorf("a format character %q survived stripControl: %q", hidden, got)
|
||||
}
|
||||
}
|
||||
// The legible text still comes through — this is stripping, not deletion.
|
||||
if got := DisplayField("deploy \u202egnp.txt.exe"); !strings.Contains(got, "gnp.txt.exe") {
|
||||
t.Errorf("stripping the override ate the filename: %q", got)
|
||||
}
|
||||
// And a newline in a transcript body is still legitimate content, so
|
||||
// stripControl must not have widened into it.
|
||||
got, _ := stripControlWithBoundaries("line one\nline two")
|
||||
if !strings.Contains(got, "\n") {
|
||||
t.Errorf("stripControl removed a legitimate newline: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE OTHER DIRECTION OF THE SAME COMPOSITION. Stripping controls can assemble
|
||||
// a split key the patterns could not see (the test above), and it can also
|
||||
// ERASE the word boundary an intact key needs: "progress\rsk-ant-…" was a
|
||||
// recognizable key after a carriage return and became "progresssk-ant-…", a
|
||||
// mid-word run \bsk-ant- refuses to match, so the whole key persisted through
|
||||
// messageEvent. Both must hold at once; reversing the two calls would trade one
|
||||
// for the other, so redaction runs on both sides of normalization.
|
||||
func TestAnIntactKeyAfterARemovedSeparatorIsStillRedacted(t *testing.T) {
|
||||
keys := []struct{ name, value string }{
|
||||
{"anthropic key", "sk-ant-api03-" + strings.Repeat("A", 24)},
|
||||
{"github pat", "ghp_" + strings.Repeat("B", 36)},
|
||||
{"aws access key", "AKIA" + strings.Repeat("C", 16)},
|
||||
}
|
||||
separators := []struct{ name, value string }{{"CR", "\r"}, {"NUL", "\x00"}, {"ESC", "\x1b"}, {"DEL", "\x7f"}, {"C1", "\u0085"}}
|
||||
for _, key := range keys {
|
||||
for _, sep := range separators {
|
||||
input := "progress" + sep.value + key.value + " done"
|
||||
for _, probe := range []struct {
|
||||
name string
|
||||
got string
|
||||
}{
|
||||
{"redact", redact(input)},
|
||||
{"message", str(t, messageEvent("user", input), "content")},
|
||||
{"tool result", str(t, toolResultEvent(&importCallIdentities{}, "bash", "c1", "ok", input), "output")},
|
||||
} {
|
||||
if strings.Contains(probe.got, key.value) {
|
||||
t.Errorf("%s / %s / %s: intact key survived: %q", key.name, sep.name, probe.name, probe.got)
|
||||
}
|
||||
if !strings.Contains(probe.got, "progress") || !strings.Contains(probe.got, "done") {
|
||||
t.Errorf("%s / %s / %s: surrounding text was eaten: %q", key.name, sep.name, probe.name, probe.got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legitimate layout is still kept in transcript text.
|
||||
if got := redact("line one\nline\ttwo"); got != "line one\nline\ttwo" {
|
||||
t.Errorf("newline/tab were not preserved: %q", got)
|
||||
}
|
||||
if got := redact("prefix\x00sk-note"); !strings.Contains(got, "sk-note") {
|
||||
t.Errorf("a short prefix-shaped prose token was over-redacted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombinedRemovedSeparatorsCannotHideSplitCredentials(t *testing.T) {
|
||||
secret := "ghp_" + strings.Repeat("A", 36)
|
||||
for _, separator := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "NUL", value: "\x00"},
|
||||
{name: "ESC", value: "\x1b"},
|
||||
{name: "DEL", value: "\x7f"},
|
||||
{name: "C1", value: "\u0085"},
|
||||
} {
|
||||
t.Run(separator.name, func(t *testing.T) {
|
||||
input := "progress" + separator.value + secret[:22] + separator.value + secret[22:]
|
||||
for _, probe := range []struct {
|
||||
name string
|
||||
got string
|
||||
}{
|
||||
{name: "transcript", got: redact(input)},
|
||||
{name: "stored message", got: str(t, messageEvent("user", input), "content")},
|
||||
{name: "stored tool result", got: str(t, toolResultEvent(&importCallIdentities{}, "shell", "call", tools.StatusOK, input), "output")},
|
||||
{name: "display", got: DisplayField(input)},
|
||||
} {
|
||||
if strings.Contains(probe.got, secret) {
|
||||
t.Fatalf("%s reassembled and exposed a split credential: %q", probe.name, probe.got)
|
||||
}
|
||||
if !strings.Contains(probe.got, "progress") || !strings.Contains(probe.got, "[REDACTED]") {
|
||||
t.Fatalf("%s did not preserve readable text and a redaction marker: %q", probe.name, probe.got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
input := "progress\x00" + secret[:22] + "\x00" + secret[22:]
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
session, err := store.Create(sessions.CreateInput{SessionID: "combined_redaction"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.AppendEvents(session.SessionID, []sessions.AppendEventInput{
|
||||
messageEvent("user", input),
|
||||
toolResultEvent(&importCallIdentities{}, "shell", "call", tools.StatusOK, input),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range events {
|
||||
payload := string(event.Payload)
|
||||
if strings.Contains(payload, secret) || !strings.Contains(payload, "[REDACTED]") {
|
||||
t.Fatalf("persisted %s payload did not retain the redaction boundary: %s", event.Type, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayFieldDoesNotLeakCredentialFragmentsAcrossNormalizedSeparators(t *testing.T) {
|
||||
key := "sk-proj-" + strings.Repeat("B", 87)
|
||||
separators := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "ESC", value: "\x1b"},
|
||||
{name: "NUL", value: "\x00"},
|
||||
{name: "NEL", value: "\u0085"},
|
||||
{name: "zero width space", value: "\u200b"},
|
||||
{name: "right-to-left override", value: "\u202e"},
|
||||
{name: "carriage return", value: "\r"},
|
||||
{name: "tab", value: "\t"},
|
||||
{name: "CRLF", value: "\r\n"},
|
||||
{name: "space", value: " "},
|
||||
{name: "two spaces", value: " "},
|
||||
{name: "CSI color", value: "\x1b[31m"},
|
||||
{name: "OSC title", value: "\x1b]0;title\a"},
|
||||
}
|
||||
|
||||
for _, separator := range separators {
|
||||
t.Run(separator.name, func(t *testing.T) {
|
||||
got := DisplayField("problem " + key[:48] + separator.value + key[48:] + " end")
|
||||
assertNoCredentialRun(t, got, key, 8)
|
||||
if !strings.Contains(got, "problem") || !strings.Contains(got, "end") || !strings.Contains(got, "[REDACTED]") {
|
||||
t.Fatalf("DisplayField did not retain readable context and a redaction marker: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
got := DisplayField("problem " + key[:13] + "\x1b[31m" + key[13:] + " end")
|
||||
assertNoCredentialRun(t, got, key, 8)
|
||||
if !strings.Contains(got, "problem") || !strings.Contains(got, "end") || !strings.Contains(got, "[REDACTED]") {
|
||||
t.Fatalf("DisplayField did not retain context for an early CSI split: %q", got)
|
||||
}
|
||||
|
||||
for _, splitPoints := range [][]int{{20, 60}, {10, 20, 30, 40, 50, 60, 70, 80, 90}} {
|
||||
var split strings.Builder
|
||||
start := 0
|
||||
for _, end := range splitPoints {
|
||||
split.WriteString(key[start:end])
|
||||
split.WriteByte('\t')
|
||||
start = end
|
||||
}
|
||||
split.WriteString(key[start:])
|
||||
got := DisplayField("problem " + split.String() + " end")
|
||||
assertNoCredentialRun(t, got, key, 8)
|
||||
if !strings.Contains(got, "problem") || !strings.Contains(got, "end") || !strings.Contains(got, "[REDACTED]") {
|
||||
t.Fatalf("DisplayField did not retain context for split points %v: %q", splitPoints, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayFieldFragmentProbeIsBounded(t *testing.T) {
|
||||
// Repeated split prefix-shaped text forces the expensive branch at many
|
||||
// starting positions. The injected detector makes the work count exact and
|
||||
// independent of machine speed.
|
||||
input := strings.Repeat("g h p _ a ", 2048)
|
||||
probes := 0
|
||||
maxProbe := 0
|
||||
got, _ := redactDisplaySpaceSplitsWithProbe(input, nil, func(candidate string) bool {
|
||||
probes++
|
||||
if len(candidate) > maxProbe {
|
||||
maxProbe = len(candidate)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if got != input {
|
||||
t.Fatal("a detector that found no secret changed the field")
|
||||
}
|
||||
if maxProbe > maxDisplaySecretProbeBytes {
|
||||
t.Fatalf("largest probe = %d bytes, want at most %d", maxProbe, maxDisplaySecretProbeBytes)
|
||||
}
|
||||
// There are fewer than len(input) possible starts, and each start can add at
|
||||
// most maxDisplaySecretProbeBytes one-byte fragments before it must stop.
|
||||
if probes > len(input)*maxDisplaySecretProbeBytes {
|
||||
t.Fatalf("detector called %d times for %d bytes", probes, len(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayFieldPrefixFilterKeepsEverySupportedSecretFamily(t *testing.T) {
|
||||
secrets := []string{
|
||||
"sk-ant-api03-" + strings.Repeat("A", 24),
|
||||
"github_pat_" + strings.Repeat("A", 22),
|
||||
"ghp_" + strings.Repeat("A", 36),
|
||||
"glpat-" + strings.Repeat("A", 12),
|
||||
"AIza" + strings.Repeat("A", 35),
|
||||
"xoxb-" + strings.Repeat("A", 10),
|
||||
"AKIA" + strings.Repeat("A", 16),
|
||||
"eyJ" + strings.Repeat("A", 10) + ".eyJ" + strings.Repeat("B", 10) + "." + strings.Repeat("C", 10),
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
split := len(secret) / 2
|
||||
got := DisplayField(secret[:split] + "\t" + secret[split:])
|
||||
if !strings.Contains(got, "[REDACTED]") {
|
||||
t.Errorf("split %q was not redacted: %q", secret, got)
|
||||
}
|
||||
assertNoCredentialRun(t, got, secret, 8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongSplitJWTFailsClosedAtTheDisplayProbeLimit(t *testing.T) {
|
||||
jwt := "eyJ" + strings.Repeat("A", 20) + "." + strings.Repeat("B", 300) + "." + strings.Repeat("C", 24)
|
||||
if contiguous := DisplayField(jwt); !strings.Contains(contiguous, "[REDACTED]") {
|
||||
t.Fatalf("contiguous JWT control was not recognized: %q", contiguous)
|
||||
}
|
||||
split := jwt[:180] + "\t" + jwt[180:]
|
||||
for _, probe := range []struct {
|
||||
name string
|
||||
got string
|
||||
}{
|
||||
{"display", DisplayField(split)},
|
||||
{"title ingress", summarizeTitle("session " + split)},
|
||||
} {
|
||||
if !strings.Contains(probe.got, "[REDACTED]") {
|
||||
t.Fatalf("%s did not fail closed for delayed JWT recognition: %q", probe.name, probe.got)
|
||||
}
|
||||
assertNoCredentialRun(t, probe.got, jwt, 8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlesRedactCompleteValuesBeforeShortening(t *testing.T) {
|
||||
pat := "ghp_" + strings.Repeat("P", 36)
|
||||
for _, input := range []string{
|
||||
strings.Repeat("x", 55) + " " + pat,
|
||||
strings.Repeat("y", 50) + ` {"password":"demoPass"}`,
|
||||
} {
|
||||
got := summarizeTitle(input)
|
||||
if strings.Contains(got, pat) || strings.Contains(got, "demoPass") {
|
||||
t.Fatalf("shortened title exposed a value recognizable only before truncation: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[REDACT") {
|
||||
t.Fatalf("shortened title lost the complete-value redaction marker: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRedactControlDense(b *testing.B) {
|
||||
for _, pairs := range []int{1000, 2000, 4000, 8000} {
|
||||
input := strings.Repeat("a\x00", pairs)
|
||||
b.Run(strconv.Itoa(pairs), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = redact(input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCredentialRun(t *testing.T, got, credential string, runLength int) {
|
||||
t.Helper()
|
||||
for start := 0; start+runLength <= len(credential); start++ {
|
||||
run := credential[start : start+runLength]
|
||||
if strings.Contains(got, run) {
|
||||
t.Fatalf("output leaked credential run %q at byte %d: %q", run, start, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// Adapters returns every adapter in a stable order.
|
||||
//
|
||||
// Adding an agent is meant to be this line plus one file. The order is the
|
||||
// order results are listed in when two sessions share a timestamp, so it stays
|
||||
// deterministic rather than depending on map iteration.
|
||||
func Adapters(env Env) []Adapter {
|
||||
return []Adapter{
|
||||
ClaudeCode(env),
|
||||
FactoryDroid(env),
|
||||
Pi(env),
|
||||
Codex(env),
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverAll indexes every adapter's store and returns the sessions belonging
|
||||
// to cwd, most recently updated first. An empty cwd means every session.
|
||||
//
|
||||
// One adapter failing never denies the user the others: these are undocumented
|
||||
// formats owned by other products, and one of them changing shape must not take
|
||||
// discovery down with it. Errors are returned alongside the results so a caller
|
||||
// can mention them without withholding what did work.
|
||||
func DiscoverAll(env Env, cwd string) ([]ForeignSession, []error) {
|
||||
found := []ForeignSession{}
|
||||
problems := []error{}
|
||||
for _, adapter := range Adapters(env) {
|
||||
// Source IDs are adapter-global public identities. Establish uniqueness
|
||||
// across the complete readable store before applying the workspace view;
|
||||
// otherwise discover can advertise a ref that import later finds
|
||||
// ambiguous, and durable agent:id provenance collapses two sources.
|
||||
discovered, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
problems = append(problems, errors.New(adapter.Name()+": "+err.Error()))
|
||||
continue
|
||||
}
|
||||
counts := make(map[string]int, len(discovered))
|
||||
for _, session := range discovered {
|
||||
counts[session.ID]++
|
||||
}
|
||||
ambiguous := map[string]bool{}
|
||||
for _, session := range discovered {
|
||||
if counts[session.ID] > 1 {
|
||||
if !ambiguous[session.ID] && (strings.TrimSpace(cwd) == "" || sameDir(cwd, session.Cwd)) {
|
||||
problems = append(problems, fmt.Errorf("%s: session id %q is ambiguous across multiple transcripts", adapter.Name(), DisplayField(session.ID)))
|
||||
ambiguous[session.ID] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cwd) != "" && !sameDir(cwd, session.Cwd) {
|
||||
continue
|
||||
}
|
||||
found = append(found, session)
|
||||
}
|
||||
}
|
||||
sortByRecency(found)
|
||||
return found, problems
|
||||
}
|
||||
|
||||
// ParseRef splits an "<agent>:<id>" reference and resolves the adapter.
|
||||
//
|
||||
// The agent prefix is required rather than inferred. Two agents can hold
|
||||
// sessions with the same id — they are all uuids — and guessing which one the
|
||||
// user meant would silently import the wrong conversation.
|
||||
func ParseRef(env Env, ref string) (Adapter, string, error) {
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
name, id, found := strings.Cut(trimmed, ":")
|
||||
if !found {
|
||||
return nil, "", errors.New("expected <agent>:<id>, for example claude-code:" +
|
||||
"3f2a1b4c-... — run `zero sessions discover` to list them")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
id = strings.TrimSpace(id)
|
||||
if name == "" || id == "" {
|
||||
return nil, "", errors.New("both an agent and a session id are required, as <agent>:<id>")
|
||||
}
|
||||
for _, adapter := range Adapters(env) {
|
||||
if strings.EqualFold(adapter.Name(), name) {
|
||||
return adapter, id, nil
|
||||
}
|
||||
}
|
||||
return nil, "", errors.New("unknown agent " + name + "; known agents: " + strings.Join(AdapterNames(env), ", "))
|
||||
}
|
||||
|
||||
// AdapterNames lists the agents this build can read.
|
||||
func AdapterNames(env Env) []string {
|
||||
names := []string{}
|
||||
for _, adapter := range Adapters(env) {
|
||||
names = append(names, adapter.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// importTagPrefix marks a Zero session as a copy of another agent's transcript.
|
||||
const importTagPrefix = "imported:"
|
||||
|
||||
// ImportTag is the provenance stamp an imported session carries:
|
||||
// "imported:v1:<base64url agent>:<base64url foreign session id>".
|
||||
//
|
||||
// The foreign id is part of the tag, not a second metadata field, so there is
|
||||
// exactly one record of where a session came from (repo invariant #5 — two
|
||||
// places holding the same fact will drift). It is what lets a caller tell an
|
||||
// already-imported session apart from one still only on the other agent's disk,
|
||||
// which the /resume picker needs in order not to list both.
|
||||
func ImportTag(agent string, sourceID string) string {
|
||||
return sessions.ImportedSessionTag(agent, sourceID)
|
||||
}
|
||||
|
||||
// ParseImportTag splits an import tag back into its agent and source id.
|
||||
// Reports false for a tag that is not an import stamp, including the older
|
||||
// two-part "imported:<agent>" form, which records no source id to return.
|
||||
func ParseImportTag(tag string) (agent string, sourceID string, ok bool) {
|
||||
return sessions.ParseImportedSessionTag(tag)
|
||||
}
|
||||
|
||||
// ForeignSourceRefFromTag returns the display-level "agent:id" identity used
|
||||
// to suppress a foreign picker row after it has been imported. Unlike
|
||||
// ParseImportTag, this deliberately recognizes the legacy
|
||||
// "imported:<agent>:<plaintext id>" spelling written by older builds. It is
|
||||
// not an authority check: callers deciding whether a session may inherit
|
||||
// foreign state must continue to use the strict versioned parser.
|
||||
func ForeignSourceRefFromTag(tag string) (string, bool) {
|
||||
if agent, sourceID, ok := ParseImportTag(tag); ok {
|
||||
return agent + ":" + sourceID, true
|
||||
}
|
||||
trimmed := strings.TrimSpace(tag)
|
||||
rest := strings.TrimPrefix(trimmed, importTagPrefix)
|
||||
if rest == trimmed || strings.HasPrefix(rest, "v1:") {
|
||||
return "", false
|
||||
}
|
||||
agent, sourceID, found := strings.Cut(rest, ":")
|
||||
agent = strings.TrimSpace(agent)
|
||||
sourceID = strings.TrimSpace(sourceID)
|
||||
if !found || agent == "" || sourceID == "" {
|
||||
return "", false
|
||||
}
|
||||
return agent + ":" + sourceID, true
|
||||
}
|
||||
|
||||
// ImportedAgent is the agent a session was imported from, or "" for a session
|
||||
// Zero produced itself. Unlike ParseImportTag this accepts the older
|
||||
// "imported:<agent>" form, so sessions imported before the tag carried a source
|
||||
// id still group under the right agent.
|
||||
func ImportedAgent(tag string) string {
|
||||
if agent, _, ok := ParseImportTag(tag); ok {
|
||||
return agent
|
||||
}
|
||||
rest := strings.TrimSpace(tag)
|
||||
trimmed := strings.TrimPrefix(rest, importTagPrefix)
|
||||
if trimmed == rest {
|
||||
return ""
|
||||
}
|
||||
agent, _, _ := strings.Cut(trimmed, ":")
|
||||
return strings.TrimSpace(agent)
|
||||
}
|
||||
|
||||
// ImportResult reports what an import produced.
|
||||
type ImportResult struct {
|
||||
Session sessions.Metadata
|
||||
Events int
|
||||
Source ForeignSession
|
||||
}
|
||||
|
||||
var ErrNoImportableContent = errors.New("foreign session has no importable content")
|
||||
|
||||
// Import copies one foreign session into the Zero session store and returns the
|
||||
// new Zero session.
|
||||
//
|
||||
// The store assigns the id rather than deriving one from the foreign id. An
|
||||
// import is a SNAPSHOT of another agent's transcript at a moment in time, and
|
||||
// that session may well be continued in its own tool afterwards; a second
|
||||
// import is therefore a legitimate second snapshot, not a mistake to be refused.
|
||||
// Provenance lives in the tag ("imported:claude-code:<foreign session id>") and
|
||||
// in the title.
|
||||
//
|
||||
// Nothing is written to the foreign store at any point.
|
||||
func Import(store *sessions.Store, adapter Adapter, id string, options ReadOptions) (ImportResult, error) {
|
||||
source, err := describe(adapter, id)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return ImportSource(store, adapter, source, options)
|
||||
}
|
||||
|
||||
// ImportSource imports the exact discovery row selected by a caller. Picker
|
||||
// flows use this form so a file disappearing or a same-ID file appearing after
|
||||
// rendering cannot silently redirect the selection.
|
||||
func ImportSource(store *sessions.Store, adapter Adapter, source ForeignSession, options ReadOptions) (ImportResult, error) {
|
||||
id := source.ID
|
||||
if adapter == nil || !strings.EqualFold(strings.TrimSpace(source.Agent), adapter.Name()) || strings.TrimSpace(id) == "" {
|
||||
return ImportResult{}, errors.New("foreign session source does not match its adapter")
|
||||
}
|
||||
// Exact snapshots prevent redirection, but they cannot make a duplicate
|
||||
// adapter ID a durable identity. Reject the same ambiguity as CLI import.
|
||||
if _, err := describe(adapter, id); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.Cwd) == "" {
|
||||
options.Cwd = source.Cwd
|
||||
}
|
||||
events, err := adapter.Read(source, options)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
if !hasImportableSourceContent(events) {
|
||||
return ImportResult{}, fmt.Errorf("import %s: %w", id, ErrNoImportableContent)
|
||||
}
|
||||
// The imported transcript crosses a model trust boundary. Persist one
|
||||
// generated note before any foreign turn so every resume projection tells the
|
||||
// continuing model that history is reference context, not instructions or
|
||||
// inherited authorization. This sits outside the adapter's source-event cap:
|
||||
// MaxEvents describes copied transcript events, while the boundary is Zero's
|
||||
// mandatory safety metadata.
|
||||
events = append([]sessions.AppendEventInput{importBoundaryEvent(adapter.Name())}, events...)
|
||||
|
||||
created, discardCreated, err := store.CreateDiscardable(sessions.CreateInput{
|
||||
// THE STORE IS THE CHOKEPOINT FOR DISPLAY VALUES. These fields are
|
||||
// another product's bytes and every reader draws them: `zero sessions
|
||||
// list`, the /resume picker, the import summary, the workspace warning.
|
||||
// stripControl was not enough for a stored value — it deliberately keeps
|
||||
// newlines, which is right for a transcript line and wrong for a label
|
||||
// drawn as one row, and it does not redact, so a title (usually the
|
||||
// user's first prompt, where a pasted key lands) stayed a live secret in
|
||||
// the store for every consumer to leak independently. Two of them did.
|
||||
// DisplayField is the one helper that strips controls FIRST and then
|
||||
// redacts, the order redaction_order_test.go pins.
|
||||
Title: DisplayField(source.Title),
|
||||
Cwd: DisplayField(source.Cwd),
|
||||
WorkspaceKey: normalizeDir(source.Cwd),
|
||||
SourceModelID: DisplayField(source.ModelID),
|
||||
Tag: ImportTag(adapter.Name(), id),
|
||||
})
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
if _, err := store.AppendEvents(created.SessionID, events); err != nil {
|
||||
cleanupErr := discardCreated()
|
||||
if cleanupErr != nil {
|
||||
return ImportResult{}, errors.Join(
|
||||
fmt.Errorf("import %s into zero session %s: %w", id, created.SessionID, err),
|
||||
fmt.Errorf("clean up failed import: %w", cleanupErr),
|
||||
)
|
||||
}
|
||||
return ImportResult{}, fmt.Errorf("import %s: %w", id, err)
|
||||
}
|
||||
return ImportResult{Session: created, Events: len(events), Source: source}, nil
|
||||
}
|
||||
|
||||
// describe finds the index entry for an id so the imported session inherits the
|
||||
// title, cwd and model. Discovery is unfiltered here because the session being
|
||||
// imported need not belong to the current directory — importing a session from
|
||||
// another workspace is a reasonable thing to want.
|
||||
func describe(adapter Adapter, id string) (ForeignSession, error) {
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil {
|
||||
return ForeignSession{}, err
|
||||
}
|
||||
matches := []ForeignSession{}
|
||||
for _, session := range found {
|
||||
if session.ID == id {
|
||||
matches = append(matches, session)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return ForeignSession{}, fmt.Errorf("%s session id %q is ambiguous across %d transcripts; remove or rename the duplicate before importing", adapter.Name(), DisplayField(id), len(matches))
|
||||
}
|
||||
return ForeignSession{}, errors.New("no " + adapter.Name() + " session with id " + id +
|
||||
" — run `zero sessions discover --all` to list them")
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
type invalidImportAdapter struct{}
|
||||
|
||||
func (invalidImportAdapter) Name() string { return "invalid" }
|
||||
func (invalidImportAdapter) Discover(string) ([]ForeignSession, error) {
|
||||
return []ForeignSession{{Agent: "invalid", ID: "broken", Title: "broken"}}, nil
|
||||
}
|
||||
|
||||
func TestDuplicateAdapterIDsAreRejectedAcrossDiscoveryAndImportScopes(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
older := filepath.Join(home, ".claude", "projects", "-old", "duplicate.jsonl")
|
||||
newer := filepath.Join(home, ".claude", "projects", "-new", "duplicate.jsonl")
|
||||
writeFile(t, older, `{"type":"user","cwd":"/old","sessionId":"duplicate","message":{"role":"user","content":"older content","model":"older-model"}}`+"\n")
|
||||
writeFile(t, newer, `{"type":"user","cwd":"/new","sessionId":"duplicate","message":{"role":"user","content":"newer content","model":"newer-model"}}`+"\n")
|
||||
oldTime := time.Now().Add(-time.Hour)
|
||||
newTime := time.Now()
|
||||
if err := os.Chtimes(older, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(newer, newTime, newTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 2 {
|
||||
t.Fatalf("discover duplicate IDs: %v (%d results)", err, len(found))
|
||||
}
|
||||
if found[0].Cwd != "/new" {
|
||||
t.Fatalf("newest selected row = %+v, want /new", found[0])
|
||||
}
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
if _, err := ImportSource(store, adapter, found[0], ReadOptions{}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Fatalf("exact-source import error = %v, want adapter-global duplicate ambiguity", err)
|
||||
}
|
||||
|
||||
if _, err := Import(store, adapter, "duplicate", ReadOptions{}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Fatalf("ID-only import error = %v, want duplicate ambiguity", err)
|
||||
}
|
||||
listed, problems := DiscoverAll(testEnv(home, nil), "")
|
||||
for _, session := range listed {
|
||||
if session.Agent == "claude-code" && session.ID == "duplicate" {
|
||||
t.Fatalf("ambiguous duplicate was presented as importable: %+v", session)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(problems), "ambiguous") {
|
||||
t.Fatalf("duplicate discovery problems = %v, want ambiguity", problems)
|
||||
}
|
||||
for _, cwd := range []string{"/old", "/new"} {
|
||||
listed, problems := DiscoverAll(testEnv(home, nil), cwd)
|
||||
if len(listed) != 0 || !strings.Contains(fmt.Sprint(problems), "ambiguous") {
|
||||
t.Fatalf("scoped discovery for %s = %+v, %v; want no advertised duplicate and an ambiguity warning", cwd, listed, problems)
|
||||
}
|
||||
}
|
||||
metas, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Fatalf("ambiguous imports left durable provenance: %+v", metas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsTranscriptChangedAfterDiscovery(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
path := filepath.Join(home, ".claude", "projects", "-w", "changing.jsonl")
|
||||
writeFile(t, path, `{"type":"user","cwd":"/w","sessionId":"changing","message":{"role":"user","content":"before"}}`+"\n")
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover: %v (%d results)", err, len(found))
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.WriteString(`{"type":"assistant","cwd":"/w","message":{"role":"assistant","content":"after"}}` + "\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
if _, err := ImportSource(store, adapter, found[0], ReadOptions{}); err == nil || !strings.Contains(err.Error(), "changed after discovery") {
|
||||
t.Fatalf("changed-source import error = %v", err)
|
||||
}
|
||||
metas, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Fatalf("changed source left an imported session: %+v", metas)
|
||||
}
|
||||
}
|
||||
func (invalidImportAdapter) Read(ForeignSession, ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
return []sessions.AppendEventInput{{Type: sessions.EventMessage, Payload: map[string]any{
|
||||
"role": "user", "content": "hello", "invalid": make(chan int),
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
type staticImportAdapter struct {
|
||||
events []sessions.AppendEventInput
|
||||
}
|
||||
|
||||
func (staticImportAdapter) Name() string { return "static" }
|
||||
func (a staticImportAdapter) Discover(string) ([]ForeignSession, error) {
|
||||
return []ForeignSession{{Agent: a.Name(), ID: "one", Title: "one", Cwd: "/w"}}, nil
|
||||
}
|
||||
func (a staticImportAdapter) Read(ForeignSession, ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
return append([]sessions.AppendEventInput{}, a.events...), nil
|
||||
}
|
||||
|
||||
func TestImportRejectsDiagnosticOnlyContentButKeepsPartialHistory(t *testing.T) {
|
||||
diagnostic := sessions.AppendEventInput{
|
||||
Type: sessions.EventError, Payload: map[string]any{"message": "one source record was omitted"},
|
||||
}
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
if _, err := Import(store, staticImportAdapter{events: []sessions.AppendEventInput{diagnostic}}, "one", ReadOptions{}); !errors.Is(err, ErrNoImportableContent) {
|
||||
t.Fatalf("diagnostic-only import error = %v, want ErrNoImportableContent", err)
|
||||
}
|
||||
metas, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Fatalf("diagnostic-only import created a local session: %+v", metas)
|
||||
}
|
||||
|
||||
partial := staticImportAdapter{events: []sessions.AppendEventInput{
|
||||
{Type: sessions.EventMessage, Payload: map[string]any{"role": "user", "content": "retained question"}},
|
||||
diagnostic,
|
||||
}}
|
||||
result, err := Import(store, partial, "one", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("partial import: %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(result.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 3 || events[0].Type != sessions.EventMessage || events[1].Type != sessions.EventMessage || events[2].Type != sessions.EventError {
|
||||
t.Fatalf("partial import events = %+v", events)
|
||||
}
|
||||
var boundary map[string]any
|
||||
if err := json.Unmarshal(events[0].Payload, &boundary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !NoteEventIsBoundary(boundary) || !strings.Contains(fmt.Sprint(boundary["content"]), "reference context only") {
|
||||
t.Fatalf("first event is not the import trust boundary: %+v", boundary)
|
||||
}
|
||||
prepared, err := sessions.PrepareExec(sessions.PrepareExecOptions{Store: store, Resume: result.Session.SessionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := sessions.FormatExecPrompt("continue", prepared)
|
||||
if !strings.Contains(prompt, "reference context only") || !strings.Contains(prompt, "retained question") {
|
||||
t.Fatalf("resumed prompt lost boundary or retained history:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// WHAT THE STORE HOLDS IS WHAT EVERY CONSUMER DRAWS. The import used
|
||||
// stripControl on the title and nothing at all on the cwd, which left two
|
||||
// separate hazards in the record itself: stripControl deliberately keeps
|
||||
// newlines — right for a transcript line, wrong for a label rendered as one row
|
||||
// — and it does not redact, so a title (usually the user's first prompt, which
|
||||
// is exactly where a pasted key lands) stayed a live credential for `zero
|
||||
// sessions list`, the /resume picker and the import summary to leak
|
||||
// independently. Sanitizing per consumer is how one of them gets forgotten;
|
||||
// this pins the chokepoint instead.
|
||||
func TestAnImportedSessionStoresADisplaySafeTitleAndCwd(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
transcript := filepath.Join(home, ".claude", "projects", "-w", "hostile.jsonl")
|
||||
// \u001b, \u0007 and \u000d are how a control byte actually reaches these
|
||||
// fields: encoding/json rejects a raw one inside a string, so a transcript
|
||||
// that carries an escape carries it escaped.
|
||||
writeFile(t, transcript, strings.Join([]string{
|
||||
`{"type":"user","cwd":"/w/\u001b[2Kmoved\u000dhidden/proj","sessionId":"hostile","message":{"role":"user","content":"hi","model":"claude\u001b[2K-opus\nsk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA"}}`,
|
||||
`{"type":"ai-title","aiTitle":"deploy\u0007 it\nwith key sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA","sessionId":"hostile"}`,
|
||||
}, "\n")+"\n")
|
||||
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := Import(store, adapter, "hostile", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
|
||||
// Exact values, not "contains no escape": the readable text has to survive,
|
||||
// or a sanitizer that deleted the field would pass. The newline became a
|
||||
// SPACE rather than vanishing, which is what leaves the word boundary the
|
||||
// secret pattern anchors on — see DisplayField.
|
||||
const wantTitle = "deploy it with key [REDACTED]"
|
||||
if result.Session.Title != wantTitle {
|
||||
t.Errorf("stored title = %q, want %q", result.Session.Title, wantTitle)
|
||||
}
|
||||
// The complete ANSI erase-line sequence is gone and the return left a space
|
||||
// behind it, so the stored path is one line and still legible.
|
||||
const wantCwd = "/w/moved hidden/proj"
|
||||
if result.Session.Cwd != wantCwd {
|
||||
t.Errorf("stored cwd = %q, want %q", result.Session.Cwd, wantCwd)
|
||||
}
|
||||
const wantWorkspaceKey = "/w/\x1b[2Kmoved\rhidden/proj"
|
||||
if result.Session.WorkspaceKey != filepath.Clean(wantWorkspaceKey) {
|
||||
t.Errorf("workspace key = %q, want %q", result.Session.WorkspaceKey, filepath.Clean(wantWorkspaceKey))
|
||||
}
|
||||
const wantModel = "claude-opus [REDACTED]"
|
||||
if result.Session.ModelID != "" || result.Session.SourceModelID != wantModel {
|
||||
t.Errorf("stored operational/source model = %q / %q, want empty / %q", result.Session.ModelID, result.Session.SourceModelID, wantModel)
|
||||
}
|
||||
|
||||
// And the record on disk, not merely the value handed back: the metadata is
|
||||
// re-read by every later `zero sessions` verb and by /resume.
|
||||
reloaded, err := store.Get(result.Session.SessionID)
|
||||
if err != nil || reloaded == nil {
|
||||
t.Fatalf("reloading the imported session: %v", err)
|
||||
}
|
||||
if reloaded.Title != wantTitle || reloaded.Cwd != wantCwd || reloaded.WorkspaceKey != filepath.Clean(wantWorkspaceKey) || reloaded.ModelID != "" || reloaded.SourceModelID != wantModel {
|
||||
t.Errorf("reloaded title/cwd/operational/source model = %q / %q / %q / %q, want %q / %q / empty / %q",
|
||||
reloaded.Title, reloaded.Cwd, reloaded.ModelID, reloaded.SourceModelID, wantTitle, wantCwd, wantModel)
|
||||
}
|
||||
if got := sessions.OperationalCwd(*reloaded); got != filepath.Clean(wantWorkspaceKey) {
|
||||
t.Errorf("operational cwd = %q, want canonical key %q", got, filepath.Clean(wantWorkspaceKey))
|
||||
}
|
||||
if got := sessions.OperationalCwd(sessions.Metadata{Cwd: "/legacy/workspace"}); got != "/legacy/workspace" {
|
||||
t.Errorf("older metadata operational cwd = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A TITLE IS THE USER'S FIRST PROMPT, so a credential in it arrives on a line of
|
||||
// its own far more often than inline — and the separator is what decided whether
|
||||
// redaction fired. Every secret pattern anchors on \b, so deleting the newline
|
||||
// glued the preceding word onto the shape and the match stopped happening;
|
||||
// "key:" happened to still match because a colon is already a boundary, which is
|
||||
// how a test written with punctuation would have passed while the real case
|
||||
// leaked. Both spellings are pinned here.
|
||||
func TestACredentialAfterALineBreakIsStillRedactedInAMetadataField(t *testing.T) {
|
||||
key := "sk-ant-api03-" + strings.Repeat("A", 24)
|
||||
for _, separator := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "newline", value: "\n"},
|
||||
{name: "tab", value: "\t"},
|
||||
{name: "carriage return", value: "\r"},
|
||||
} {
|
||||
t.Run(separator.name, func(t *testing.T) {
|
||||
// "key" ends in a word character, so deleting the separator destroys
|
||||
// the boundary. This is the spelling that leaked.
|
||||
got := DisplayField("rotate the key" + separator.value + key)
|
||||
if strings.Contains(got, key) {
|
||||
t.Errorf("a credential after a %s reached a display field verbatim: %q", separator.name, got)
|
||||
}
|
||||
if got != "rotate the key [REDACTED]" {
|
||||
t.Errorf("DisplayField = %q, want %q", got, "rotate the key [REDACTED]")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRemovesSessionWhenAppendingEventsFails(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
if _, err := Import(store, invalidImportAdapter{}, "broken", ReadOptions{}); err == nil {
|
||||
t.Fatal("import with an unencodable event unexpectedly succeeded")
|
||||
}
|
||||
metas, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Fatalf("failed import left a durable session behind: %+v", metas)
|
||||
}
|
||||
}
|
||||
|
||||
// THE FILE THAT WAS VERIFIED IS THE FILE THAT IS READ. Read used to check the
|
||||
// selected path through one handle, translate through a second open and check
|
||||
// through a third; a writer that renamed A aside, placed B under A's name for
|
||||
// the middle open and restored A before the last check passed both snapshot
|
||||
// checks while B's bytes were imported under A's provenance. The seam schedules
|
||||
// exactly that replacement between the verified open and the first byte read.
|
||||
func TestImportReadsTheFileItVerifiedNotTheNameItWasGiven(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
path := filepath.Join(home, ".claude", "projects", "-w", "swap.jsonl")
|
||||
writeFile(t, path, `{"type":"user","cwd":"/w","sessionId":"swap","message":{"role":"user","content":"genuine A"}}`+"\n")
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover: %v (%d results)", err, len(found))
|
||||
}
|
||||
aside := path + ".aside"
|
||||
afterSourceOpen = func(opened string) {
|
||||
if opened != path {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(path, aside); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, path, `{"type":"user","cwd":"/w","sessionId":"swap","message":{"role":"user","content":"impostor B"}}`+"\n")
|
||||
}
|
||||
t.Cleanup(func() { afterSourceOpen = nil })
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := ImportSource(store, adapter, found[0], ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("import of the verified handle failed: %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(result.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := ""
|
||||
for _, event := range events {
|
||||
joined += string(event.Payload)
|
||||
}
|
||||
if !strings.Contains(joined, "genuine A") || strings.Contains(joined, "impostor B") {
|
||||
t.Fatalf("imported bytes did not come from the verified file:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// A source written DURING the read is refused on the same handle, as the
|
||||
// existing changed-source failure, and nothing is committed.
|
||||
func TestImportRefusesASourceWrittenDuringTheRead(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
path := filepath.Join(home, ".claude", "projects", "-w", "grow.jsonl")
|
||||
writeFile(t, path, `{"type":"user","cwd":"/w","sessionId":"grow","message":{"role":"user","content":"before"}}`+"\n")
|
||||
adapter := ClaudeCode(testEnv(home, nil))
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover: %v (%d results)", err, len(found))
|
||||
}
|
||||
afterSourceOpen = func(opened string) {
|
||||
if opened != path {
|
||||
return
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.WriteString(`{"type":"assistant","message":{"role":"assistant","content":"after"}}` + "\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Keep the timestamp moving even on coarse filesystems.
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
_ = os.Chtimes(path, future, future)
|
||||
}
|
||||
t.Cleanup(func() { afterSourceOpen = nil })
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
if _, err := ImportSource(store, adapter, found[0], ReadOptions{}); err == nil || !strings.Contains(err.Error(), "changed after discovery") {
|
||||
t.Fatalf("import of a source written during the read = %v, want changed-source refusal", err)
|
||||
}
|
||||
metas, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Fatalf("a refused import left a session: %+v", metas)
|
||||
}
|
||||
}
|
||||
|
||||
// The Codex reader shares the handle contract: same verified handle from the
|
||||
// identity check to the last byte, same refusal of a source that moves.
|
||||
func TestCodexImportReadsTheFileItVerifiedNotTheNameItWasGiven(t *testing.T) {
|
||||
env, path := writeCodexStore(t,
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"019f73d7-e215-7ce0-ab38-d9e6db354717","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"genuine A"}]}}`,
|
||||
)
|
||||
adapter := Codex(env)
|
||||
found, err := adapter.Discover("")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Fatalf("discover: %v (%d results)", err, len(found))
|
||||
}
|
||||
afterSourceOpen = func(opened string) {
|
||||
if opened != path {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(path, path+".aside"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, path, strings.Join([]string{
|
||||
`{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"019f73d7-e215-7ce0-ab38-d9e6db354717","cwd":"/w"}}`,
|
||||
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"impostor B"}]}}`,
|
||||
}, "\n")+"\n")
|
||||
}
|
||||
t.Cleanup(func() { afterSourceOpen = nil })
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(t.TempDir(), "sessions")})
|
||||
result, err := ImportSource(store, adapter, found[0], ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("import of the verified handle failed: %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(result.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := ""
|
||||
for _, event := range events {
|
||||
joined += string(event.Payload)
|
||||
}
|
||||
if !strings.Contains(joined, "genuine A") || strings.Contains(joined, "impostor B") {
|
||||
t.Fatalf("imported bytes did not come from the verified file:\n%s", joined)
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{"type":"mode","mode":"default"}
|
||||
{"type":"user","cwd":"/Users/example/workspace/demo","gitBranch":"main","sessionId":"fixture-session","timestamp":"2026-08-01T10:00:00.000Z","message":{"role":"user","content":"Add a --verbose flag to the CLI and cover it with a test"}}
|
||||
{"type":"ai-title","aiTitle":"Add --verbose flag to the CLI","sessionId":"fixture-session"}
|
||||
{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"I'll start by reading the CLI entrypoint."}]}}
|
||||
{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"call-1","name":"Read","input":{"file_path":"/Users/example/workspace/demo/cmd/main.go"}}]}}
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-1","is_error":false,"content":"package main\n\nfunc main() {}"}]}}
|
||||
{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"call-2","name":"Edit","input":{"file_path":"/Users/example/workspace/demo/cmd/main.go"}}]}}
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-2","is_error":false,"content":"edited"}]}}
|
||||
{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"Done — the flag is wired up and the test passes."}]}}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
{"type": "session_meta", "payload": {"id": "00000000-0000-4000-8000-000000000002", "cwd": "/w", "timestamp": "2026-08-01T10:00:00Z"}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 0"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 1"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 2"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 3"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 4"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 5"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 6"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 7"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 8"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 9"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 10"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 11"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 12"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 13"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 14"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 15"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 16"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 17"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 18"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 19"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 20"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 21"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 22"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 23"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 24"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 25"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 26"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 27"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 28"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 29"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 30"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 31"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 32"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 33"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 34"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 35"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 36"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 37"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 38"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 39"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 40"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 41"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 42"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 43"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 44"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 45"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 46"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 47"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 48"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 49"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 50"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 51"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 52"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 53"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 54"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 55"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 56"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 57"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 58"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 59"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 60"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 61"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 62"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 63"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 64"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 65"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 66"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 67"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 68"}]}}
|
||||
{"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "step 69"}]}}
|
||||
{"type": "turn_context", "payload": {"model": "gpt-5.6-sol", "cwd": "/w"}}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{"type":"session_meta","timestamp":"2026-08-01T10:00:00.000Z","payload":{"session_id":"fixture0-0000-0000-0000-000000000001","cwd":"/Users/example/workspace/demo"}}
|
||||
{"type":"turn_context","payload":{"model":"gpt-5.6-sol","effort":"high","summary":"auto"}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Add a --verbose flag to the CLI and cover it with a test"}]}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll add the flag and a test."}]}}
|
||||
{"type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":\"go test ./...\"}","call_id":"fc-1"}}
|
||||
{"type":"response_item","payload":{"type":"function_call_output","call_id":"fc-1","output":"ok"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"bridge-session","sessionId":"b0000000-0000-4000-8000-000000000001","bridgeSessionId":"bridge_01","lastSequenceNum":0}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"queue-operation","timestamp":"2026-01-01T00:00:00Z"}
|
||||
{"type":"user","cwd":"/w","timestamp":"2026-01-01T00:00:01Z","message":{"role":"user","model":"m","content":"port the parser"}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type": "queue-operation", "timestamp": "2026-01-01T00:00:00Z"}
|
||||
{"type": "user", "cwd": "/w", "timestamp": "2026-01-01T00:00:01Z", "message": {"role": "user", "model": "m", "content": "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp"}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"type":"last-prompt","prompt":""}
|
||||
{"type":"mode","mode":"default"}
|
||||
{"type":"permission-mode","permissionMode":"default"}
|
||||
@@ -0,0 +1,944 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// The payload field names below are a CONTRACT with the TUI, not a convention.
|
||||
// internal/tui/session.go's transcriptRowsFromSessionEvents reads exactly these
|
||||
// keys ("role", "content", "name", "toolCallId", "arguments", "status",
|
||||
// "output"); a misspelling renders an empty row and reports no error anywhere.
|
||||
// Every event this package produces is built by one of the four constructors
|
||||
// here so there is a single place for those names to be right.
|
||||
//
|
||||
// These constructors are also the redaction chokepoint (repo invariant #6).
|
||||
// Imported text is untrusted input (invariant #8) — a foreign transcript can
|
||||
// contain a key the other agent echoed into its own log — and routing every
|
||||
// event through here means no future caller can add an unredacted path without
|
||||
// deleting a call they can see.
|
||||
|
||||
// redact runs secret redaction AND control-stripping on imported text. Every
|
||||
// field a foreign transcript supplies routes through here — the content-bearing
|
||||
// ones and the structural ones (role, name, toolCallId) alike — because a
|
||||
// malicious transcript can hide a credential in any of them and Zero then
|
||||
// persists and renders it. This is the redaction chokepoint (invariant #6): no
|
||||
// imported byte reaches a picker row or transcript line as a secret or as a live
|
||||
// control sequence.
|
||||
// THE ORDER IS THE WHOLE GUARANTEE. Strip first, then match.
|
||||
//
|
||||
// RedactString matches secrets by SHAPE, and stripControl deletes a control byte
|
||||
// without leaving a gap, so it is also a REASSEMBLER. Running it second meant a
|
||||
// transcript could split a credential with a NUL, an ESC or any C1 byte, sail
|
||||
// past the shape patterns because neither half looks like a key, and then have
|
||||
// the halves rejoined on the way out. Every shape leaked that way: sk-ant-,
|
||||
// ghp_, AKIA. Normalizing first means the patterns see the text after the
|
||||
// control classes this sanitizer actually removes (C0, DEL, C1, and selected
|
||||
// format runes). Combining marks and other non-control Unicode remain unchanged
|
||||
// and are not claimed as part of this redaction boundary.
|
||||
//
|
||||
// Same defect as #835, where an MCP failure reason was redacted before the
|
||||
// terminal sanitizer rejoined its halves. Any normalizer that removes bytes
|
||||
// without leaving a gap has to run BEFORE whatever matches on them.
|
||||
//
|
||||
// AND ALSO AFTER IT — BOTH DIRECTIONS HOLD AT ONCE. Stripping changes what the
|
||||
// matcher can see in two opposite ways. Removing a control INSIDE a key
|
||||
// assembles a key the patterns could not see before (the case above). Removing
|
||||
// a control immediately BEFORE an intact key erases the word boundary every
|
||||
// pattern anchors on: "progress\rsk-ant-api03-…" was a recognizable key after a
|
||||
// carriage return, and became "progresssk-ant-api03-…" — a mid-word run that
|
||||
// \bsk-ant- refuses to match — so the whole key persisted through messageEvent.
|
||||
// NUL and every other deleted separator reproduce it. DisplayField already ran
|
||||
// redaction on both sides of normalization for metadata; the transcript
|
||||
// constructors did not, and a transcript is where a pasted key actually lands.
|
||||
// Pass one catches the intact key while its separator still stands, pass two
|
||||
// catches the split key once the separator is gone. Reported by @jatmn.
|
||||
func redact(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
normalized, boundaries := stripControlWithBoundaries(value)
|
||||
return redactAtRemovedBoundaries(normalized, boundaries)
|
||||
}
|
||||
|
||||
func stripControlWithBoundaries(value string) (string, []int) {
|
||||
var b strings.Builder
|
||||
b.Grow(len(value))
|
||||
boundaries := []int{}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == '\t' || r == '\n':
|
||||
b.WriteRune(r)
|
||||
case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
|
||||
boundaries = appendBoundary(boundaries, b.Len())
|
||||
// FORMAT CHARACTERS ARE NOT CONTROL CHARACTERS, and unicode.IsControl
|
||||
// says so — but U+202E RIGHT-TO-LEFT OVERRIDE reorders everything after
|
||||
// it, so a tool name or title can be made to render as something else
|
||||
// entirely while the bytes stay innocent. Category Cf is invisible by
|
||||
// definition; nothing in a transcript needs it.
|
||||
case unicode.Is(unicode.Cf, r):
|
||||
boundaries = appendBoundary(boundaries, b.Len())
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String(), boundaries
|
||||
}
|
||||
|
||||
func appendBoundary(boundaries []int, position int) []int {
|
||||
if len(boundaries) == 0 || boundaries[len(boundaries)-1] != position {
|
||||
return append(boundaries, position)
|
||||
}
|
||||
return boundaries
|
||||
}
|
||||
|
||||
// redactAtRemovedBoundaries preserves the fact that a removed control separated
|
||||
// two adjacent bytes. The ordinary post-normalization pass catches credentials
|
||||
// assembled across an internal control. At a former boundary, a known secret
|
||||
// prefix also owns the complete adjacent token even when deleting the control
|
||||
// glued preceding prose to it and erased the redactor's word boundary.
|
||||
//
|
||||
// Each boundary and each byte in a matched token is visited at most a constant
|
||||
// number of times. The previous implementation redacted every remaining suffix
|
||||
// once per removed byte, making a control-dense accepted value quadratic.
|
||||
func redactAtRemovedBoundaries(value string, boundaries []int) string {
|
||||
const boundaryHint = "\ue000"
|
||||
spans := make([]byteSpan, 0)
|
||||
coveredThrough := 0
|
||||
for _, position := range boundaries {
|
||||
if position < coveredThrough || position < 0 || position >= len(value) {
|
||||
continue
|
||||
}
|
||||
if !fullDisplaySecretPrefixAt(value[position:]) {
|
||||
continue
|
||||
}
|
||||
probeEnd := position
|
||||
for probeEnd < len(value) && probeEnd-position < maxDisplaySecretProbeBytes && isTextSecretByte(value[probeEnd]) {
|
||||
probeEnd++
|
||||
}
|
||||
candidate := value[position:probeEnd]
|
||||
probe := redaction.RedactString(boundaryHint+candidate, redaction.Options{})
|
||||
detected := strings.Contains(probe, redaction.RedactedSecret)
|
||||
if !detected && len(candidate) >= maxDisplaySecretProbeBytes {
|
||||
// A supported delayed-match shape (notably a long JWT) can still be
|
||||
// incomplete at the work cap. Abandoning it would fail open.
|
||||
detected = true
|
||||
}
|
||||
if !detected {
|
||||
continue
|
||||
}
|
||||
end := probeEnd
|
||||
for end < len(value) && isTextSecretByte(value[end]) {
|
||||
end++
|
||||
}
|
||||
spans = append(spans, byteSpan{start: position, end: end})
|
||||
coveredThrough = end
|
||||
}
|
||||
for index := len(spans) - 1; index >= 0; index-- {
|
||||
span := spans[index]
|
||||
value = value[:span.start] + redaction.RedactedSecret + value[span.end:]
|
||||
}
|
||||
return redaction.RedactString(value, redaction.Options{})
|
||||
}
|
||||
|
||||
type byteSpan struct {
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// Every fixed-prefix text credential reaches its minimum recognizable shape
|
||||
// within 40 joined bytes. The larger cap leaves room for OpenAI's digit-based
|
||||
// false-positive filter while making work per possible start independent of the
|
||||
// discovery-line size. An unresolved sk- candidate at the cap fails closed.
|
||||
const maxDisplaySecretProbeBytes = 256
|
||||
|
||||
var displaySecretPrefixes = []string{
|
||||
"sk-", "github_pat_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_",
|
||||
"glpat-", "AIza", "xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-",
|
||||
"AKIA", "ASIA", "eyJ",
|
||||
}
|
||||
|
||||
// redactDisplaySpaceSplits catches credentials whose bytes were separated by
|
||||
// layout controls. DisplayField keeps those controls as a space for legibility,
|
||||
// but an attacker can put one inside a key: the ordinary matcher then redacts a
|
||||
// recognizable prefix and leaves the suffix on screen. Ask the shared redactor
|
||||
// whether the adjacent ASCII fragments form a known secret, then replace the
|
||||
// complete original span (including the display space).
|
||||
//
|
||||
// It also remaps the remaining removed-control boundaries. Their byte offsets
|
||||
// feed redactAtRemovedBoundaries, so leaving offsets from before a replacement
|
||||
// could redact unrelated text later in the field.
|
||||
func redactDisplaySpaceSplits(value string, boundaries []int) (string, []int) {
|
||||
const boundaryHint = "\ue000"
|
||||
return redactDisplaySpaceSplitsWithProbe(value, boundaries, func(candidate string) bool {
|
||||
probe := redaction.RedactString(boundaryHint+candidate, redaction.Options{})
|
||||
return strings.Contains(probe, redaction.RedactedSecret) ||
|
||||
(len(candidate) >= maxDisplaySecretProbeBytes && fullDisplaySecretPrefixAt(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
func redactDisplaySpaceSplitsWithProbe(value string, boundaries []int, detectsSecret func(string) bool) (string, []int) {
|
||||
type fragment struct{ start, end int }
|
||||
fragments := []fragment{}
|
||||
for index := 0; index < len(value); {
|
||||
if !isTextSecretByte(value[index]) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
start := index
|
||||
for index < len(value) && isTextSecretByte(value[index]) {
|
||||
index++
|
||||
}
|
||||
fragments = append(fragments, fragment{start: start, end: index})
|
||||
}
|
||||
|
||||
spans := []byteSpan{}
|
||||
for start := 0; start < len(fragments); start++ {
|
||||
var candidate strings.Builder
|
||||
for end := start; end < len(fragments); end++ {
|
||||
if end > start {
|
||||
gap := value[fragments[end-1].end:fragments[end].start]
|
||||
if strings.Trim(gap, " ") != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
fragmentText := value[fragments[end].start:fragments[end].end]
|
||||
remaining := maxDisplaySecretProbeBytes - candidate.Len()
|
||||
if len(fragmentText) > remaining {
|
||||
fragmentText = fragmentText[:remaining]
|
||||
}
|
||||
candidate.WriteString(fragmentText)
|
||||
probeText := candidate.String()
|
||||
if !displaySecretPrefixPossible(probeText) {
|
||||
break
|
||||
}
|
||||
detected := detectsSecret(probeText)
|
||||
if !detected {
|
||||
if candidate.Len() >= maxDisplaySecretProbeBytes {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Once a secret-shaped prefix is recognized, consume following long
|
||||
// fragments separated only by layout. Leaving those fragments visible
|
||||
// recreates the split-key leak; a short prose word after the key remains
|
||||
// readable and cannot expose the eight-byte run this boundary protects.
|
||||
last := end
|
||||
for next := end + 1; next < len(fragments); next++ {
|
||||
gap := value[fragments[next-1].end:fragments[next].start]
|
||||
if strings.Trim(gap, " ") != "" || fragments[next].end-fragments[next].start < 8 {
|
||||
break
|
||||
}
|
||||
last = next
|
||||
}
|
||||
spans = append(spans, byteSpan{start: fragments[start].start, end: fragments[last].end})
|
||||
start = last
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
return value, boundaries
|
||||
}
|
||||
|
||||
// Boundaries are discovered left-to-right, so overlapping spans can only
|
||||
// extend the previous span. Merging first prevents duplicate markers when a
|
||||
// credential contains more than one layout control.
|
||||
merged := spans[:0]
|
||||
for _, span := range spans {
|
||||
if len(merged) > 0 && span.start <= merged[len(merged)-1].end {
|
||||
if span.end > merged[len(merged)-1].end {
|
||||
merged[len(merged)-1].end = span.end
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged = append(merged, span)
|
||||
}
|
||||
|
||||
for i := len(merged) - 1; i >= 0; i-- {
|
||||
span := merged[i]
|
||||
value = value[:span.start] + redaction.RedactedSecret + value[span.end:]
|
||||
delta := len(redaction.RedactedSecret) - (span.end - span.start)
|
||||
for j, position := range boundaries {
|
||||
switch {
|
||||
case position >= span.start && position <= span.end:
|
||||
boundaries[j] = -1
|
||||
case position > span.end:
|
||||
boundaries[j] += delta
|
||||
}
|
||||
}
|
||||
}
|
||||
kept := boundaries[:0]
|
||||
for _, position := range boundaries {
|
||||
if position >= 0 {
|
||||
kept = append(kept, position)
|
||||
}
|
||||
}
|
||||
return value, kept
|
||||
}
|
||||
|
||||
func displaySecretPrefixPossible(candidate string) bool {
|
||||
for _, prefix := range displaySecretPrefixes {
|
||||
if strings.HasPrefix(prefix, candidate) || strings.HasPrefix(candidate, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fullDisplaySecretPrefixAt(candidate string) bool {
|
||||
for _, prefix := range displaySecretPrefixes {
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTextSecretByte(value byte) bool {
|
||||
return value >= 'a' && value <= 'z' ||
|
||||
value >= 'A' && value <= 'Z' ||
|
||||
value >= '0' && value <= '9' ||
|
||||
value == '_' || value == '-' || value == '.'
|
||||
}
|
||||
|
||||
// displayEscapeEnd returns the first byte after one ANSI CSI or OSC sequence.
|
||||
// A foreign title can contain a real escape such as ESC[31m; deleting only ESC
|
||||
// leaves the printable "[31m" bytes between credential fragments and defeats
|
||||
// the post-normalization matcher. Other ESC spellings retain their printable
|
||||
// byte and only lose the introducer, matching the prior behavior.
|
||||
func displayEscapeEnd(value string, start int) int {
|
||||
if start+1 >= len(value) {
|
||||
return start + 1
|
||||
}
|
||||
switch value[start+1] {
|
||||
case '[':
|
||||
for index := start + 2; index < len(value); index++ {
|
||||
if value[index] >= 0x40 && value[index] <= 0x7e {
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
return len(value)
|
||||
case ']':
|
||||
for index := start + 2; index < len(value); index++ {
|
||||
if value[index] == '\a' {
|
||||
return index + 1
|
||||
}
|
||||
if value[index] == 0x1b && index+1 < len(value) && value[index+1] == '\\' {
|
||||
return index + 2
|
||||
}
|
||||
}
|
||||
return len(value)
|
||||
default:
|
||||
return start + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Every event a translation produces carries sessions.ImportedEventKey. The
|
||||
// resume digest keeps only the last 80 eligible events, so the boundary note
|
||||
// persisted ahead of the transcript aged out of the window on the first resume
|
||||
// of any import longer than that, while the foreign turns it was labelling
|
||||
// stayed. The marker is what lets FormatExecPrompt tell, from the retained
|
||||
// window alone, that what it is about to hand the model is foreign — and
|
||||
// regenerate the label rather than depend on one event surviving truncation,
|
||||
// compaction, or a fork. Reported by @jatmn.
|
||||
func messageEvent(role string, content string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{
|
||||
"role": redact(role),
|
||||
"content": redact(content),
|
||||
sessions.ImportedEventKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type importCallIdentities struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func (identities *importCallIdentities) opaque(foreign string) string {
|
||||
if len(identities.key) == 0 {
|
||||
identities.key = []byte(rand.Text())
|
||||
}
|
||||
digest := hmac.New(sha256.New, identities.key)
|
||||
_, _ = digest.Write([]byte(foreign))
|
||||
return fmt.Sprintf("import-call-%x", digest.Sum(nil))
|
||||
}
|
||||
|
||||
func toolCallEvent(identities *importCallIdentities, name string, foreignCallID string, arguments string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventToolCall,
|
||||
Payload: map[string]any{
|
||||
"name": redact(name),
|
||||
// Identity is structural, not display text. Foreign ids may contain
|
||||
// secrets, and redaction is deliberately many-to-one, so persisting a
|
||||
// redacted foreign id can collapse distinct call/result pairs. This
|
||||
// per-import opaque id is non-secret and one-to-one.
|
||||
"toolCallId": identities.opaque(foreignCallID),
|
||||
"arguments": redactArguments(arguments),
|
||||
sessions.ImportedEventKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// redactArguments sanitizes a tool call's arguments as the VALUES a consumer
|
||||
// will decode, not as the bytes they are serialized in.
|
||||
//
|
||||
// arguments is JSON from every adapter that has structured calls, and JSON
|
||||
// escapes are a representation the sanitizer above cannot see through: a
|
||||
// foreign path of "\u001b[2J FORGED \u0067hp_AAAA…" contains no ESC byte and
|
||||
// no recognizable key prefix while it is encoded, so redact() passed it whole —
|
||||
// and the TUI's argHint → firstArgValue then json-decoded it on resume into an
|
||||
// actual escape followed by a complete PAT, in the tool row. A scan of the
|
||||
// encoded text can prove nothing about strings that will be unescaped later.
|
||||
// The sanitizer has to run where the value exists: decode, redact every string
|
||||
// leaf (nested included — an argument object is routinely a tree), re-encode.
|
||||
// Reported by @jatmn.
|
||||
//
|
||||
// Not JSON — Codex's custom_tool_call carries a bare script in "input" — is
|
||||
// text and is sanitized as text, exactly as before. A JSON value that is not an
|
||||
// object or array (a bare string) decodes to its leaf and is handled the same
|
||||
// way. The encoded result stays valid JSON for its existing consumers.
|
||||
func redactArguments(arguments string) string {
|
||||
trimmed := strings.TrimSpace(arguments)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
|
||||
return redact(arguments)
|
||||
}
|
||||
// First normalize every decoded key and value using the imported-text policy,
|
||||
// then retain the complete object shape while applying sensitive-key rules.
|
||||
// A leaf-only walk cannot know that an opaque value belongs to "password",
|
||||
// and leaving map keys untouched can persist a credential in a property name.
|
||||
sanitized := redaction.RedactValue(redactJSONValue(decoded), redaction.Options{})
|
||||
encoded, err := json.Marshal(sanitized)
|
||||
if err != nil {
|
||||
return redact(arguments)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// redactJSONValue walks a decoded JSON value and applies imported-text
|
||||
// normalization to string leaves and object keys. Ordinary schema keys remain
|
||||
// unchanged; credential-bearing keys are intentionally rewritten before the
|
||||
// object-aware redactor handles sensitive key/value relationships.
|
||||
func redactJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return redact(typed)
|
||||
case []any:
|
||||
for index := range typed {
|
||||
typed[index] = redactJSONValue(typed[index])
|
||||
}
|
||||
return typed
|
||||
case map[string]any:
|
||||
redacted := make(map[string]any, len(typed))
|
||||
for key := range typed {
|
||||
redacted[redact(key)] = redactJSONValue(typed[key])
|
||||
}
|
||||
return redacted
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func toolResultEvent(identities *importCallIdentities, name string, foreignCallID string, status tools.Status, output string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventToolResult,
|
||||
Payload: map[string]any{
|
||||
"name": redact(name),
|
||||
"toolCallId": identities.opaque(foreignCallID),
|
||||
"status": string(status),
|
||||
"output": redact(output),
|
||||
sessions.ImportedEventKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// noteEventSummaryKey marks a message as a Zero-generated activity summary
|
||||
// rather than a translated foreign-transcript turn. The TUI and the resume
|
||||
// digest also reads "role" and "content", so the marker itself is metadata even
|
||||
// though the generated summary remains visible to both the user and the model.
|
||||
// NoteEventIsSummary lets consumers distinguish it from a foreign turn.
|
||||
const noteEventSummaryKey = "importedActivitySummary"
|
||||
|
||||
// importBoundaryKey is owned by internal/sessions rather than here, because the
|
||||
// resume digest has to recognize the boundary without importing this package.
|
||||
const importBoundaryKey = sessions.ImportedBoundaryKey
|
||||
|
||||
func importBoundaryEvent(agentName string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{
|
||||
"role": "user",
|
||||
"content": sessions.ImportedBoundaryText(DisplayField(agentName)),
|
||||
importBoundaryKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NoteEventIsBoundary reports whether an event is Zero's generated trust
|
||||
// boundary rather than content copied from the foreign transcript.
|
||||
func NoteEventIsBoundary(payload any) bool {
|
||||
m, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
flag, _ := m[importBoundaryKey].(bool)
|
||||
return flag
|
||||
}
|
||||
|
||||
func hasImportableSourceContent(events []sessions.AppendEventInput) bool {
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case sessions.EventToolCall, sessions.EventToolResult:
|
||||
return true
|
||||
case sessions.EventMessage:
|
||||
if NoteEventIsSummary(event.Payload) || NoteEventIsBoundary(event.Payload) {
|
||||
continue
|
||||
}
|
||||
payload, ok := event.Payload.(map[string]any)
|
||||
if !ok {
|
||||
encoded, err := json.Marshal(event.Payload)
|
||||
if err != nil || json.Unmarshal(encoded, &payload) != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
role, _ := payload["role"].(string)
|
||||
content, _ := payload["content"].(string)
|
||||
if (role == "user" || role == "assistant") && strings.TrimSpace(content) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NoteEventIsSummary reports whether an event is a Zero-generated activity
|
||||
// summary message (see noteEvent) rather than a translated transcript turn. It
|
||||
// takes any so callers can pass an AppendEventInput.Payload directly.
|
||||
func NoteEventIsSummary(payload any) bool {
|
||||
m, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
flag, _ := m[noteEventSummaryKey].(bool)
|
||||
return flag
|
||||
}
|
||||
|
||||
// noteEvent carries an imported-session activity summary as an assistant
|
||||
// message. NOT EventCompaction: that type has a second contract on the replay
|
||||
// side. RehydrateEvents restructures the transcript around the last
|
||||
// EventCompaction, and an activity summary with no CompactionPayload bookkeeping
|
||||
// (no CompactableEvents, CompactedThroughSequence 0) makes rehydration hoist
|
||||
// this note to the FRONT of the transcript. EventMessage still passes
|
||||
// promptContextEvents — the resume digest — without that restructuring. The
|
||||
// summary marker keeps it distinguishable from a real assistant turn.
|
||||
func noteEvent(summary string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{
|
||||
"role": "assistant",
|
||||
"content": redact(summary),
|
||||
noteEventSummaryKey: true,
|
||||
sessions.ImportedEventKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// translateFamily1 converts a family-1 transcript into Zero events.
|
||||
//
|
||||
// The mapping is deliberately lossy in one direction only: everything that
|
||||
// affects what a reader (human or model) needs in order to continue the work is
|
||||
// kept, and everything that belongs to the other model's private machinery is
|
||||
// dropped. Zero's own resume renders these events to a text digest anyway
|
||||
// (sessions.FormatExecPrompt), so perfect structural fidelity would buy nothing.
|
||||
func translateFamily1(file readSeekStater, options ReadOptions) ([]sessions.AppendEventInput, error) {
|
||||
events := newEventTail(effectiveMaxEvents(options.MaxEvents))
|
||||
// A tool result names only the id of the call it answers, so the call's name
|
||||
// has to be carried forward. Every family-1 agent writes the tool_use before
|
||||
// the matching tool_result, so this is populated by the time it is read.
|
||||
toolNames := map[string]string{}
|
||||
identities := &importCallIdentities{}
|
||||
activity := newActivityLog(options.Cwd)
|
||||
|
||||
omitted := 0
|
||||
prefixOmitted, err := streamTailLines(file, importLineLimit, importByteLimit, func(line []byte, truncated bool) bool {
|
||||
// A RECORD TOO LONG EVEN FOR THE IMPORT CAP IS REPORTED, NOT DROPPED.
|
||||
// Skipping it silently produced a transcript that looked complete: a
|
||||
// question, no answer, then the follow-up. The marker is the honest
|
||||
// answer — the bytes are gone either way, but the reader can see it.
|
||||
if truncated {
|
||||
omitted++
|
||||
return true
|
||||
}
|
||||
var record family1Record
|
||||
if json.Unmarshal(line, &record) != nil || record.Message == nil {
|
||||
// Torn or unrecognised lines are skipped, not fatal: transcripts are
|
||||
// appended live and the final line is routinely half-written.
|
||||
return true
|
||||
}
|
||||
role := roleFor(record)
|
||||
if role == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Content is either a bare string (a plain user prompt) or an array of
|
||||
// typed blocks.
|
||||
var text string
|
||||
if json.Unmarshal(record.Message.Content, &text) == nil {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
events.add(messageEvent(role, text))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var blocks []family1Block
|
||||
if json.Unmarshal(record.Message.Content, &blocks) != nil {
|
||||
return true
|
||||
}
|
||||
for _, block := range blocks {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
if strings.TrimSpace(block.Text) != "" {
|
||||
events.add(messageEvent(role, block.Text))
|
||||
}
|
||||
case "thinking":
|
||||
// The other model's reasoning. Dropped by default: it is private
|
||||
// to that provider, frequently larger than the visible
|
||||
// conversation, and a different model continuing this work will
|
||||
// not be picking up that chain of thought.
|
||||
if options.IncludeReasoning && strings.TrimSpace(block.Thinking) != "" {
|
||||
events.add(messageEvent("reasoning", block.Thinking))
|
||||
}
|
||||
case "tool_use":
|
||||
toolNames[block.ID] = block.Name
|
||||
activity.observeCall(block.ID, block.Name, string(block.Input))
|
||||
events.add(toolCallEvent(identities, block.Name, block.ID, string(block.Input)))
|
||||
case "tool_result":
|
||||
name := toolNames[block.ToolUseID]
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
status := tools.StatusOK
|
||||
if block.IsError {
|
||||
status = tools.StatusError
|
||||
}
|
||||
output := family1ResultText(block.Content)
|
||||
activity.observeResult(block.ToolUseID, name, status, output)
|
||||
events.add(toolResultEvent(identities, name, block.ToolUseID, status, output))
|
||||
delete(toolNames, block.ToolUseID)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SAID OUT LOUD. A resumed conversation that quietly lost a record reads as
|
||||
// complete to both the user and the model continuing it — the failure this
|
||||
// makes visible is a question with no answer followed by a follow-up.
|
||||
contextEvents := activity.summaryEvents()
|
||||
if prefixOmitted {
|
||||
contextEvents = append(contextEvents, omittedPrefixEvent())
|
||||
}
|
||||
if omitted > 0 {
|
||||
contextEvents = append(contextEvents, omittedRecordsEvent(omitted))
|
||||
}
|
||||
return capTranslatedEventsDropped(events.values(), contextEvents, effectiveMaxEvents(options.MaxEvents), events.dropped), nil
|
||||
}
|
||||
|
||||
// roleFor admits only visible conversation roles. System, developer, and other
|
||||
// harness records belong to the foreign agent and must not become instructions
|
||||
// or user-visible turns in Zero.
|
||||
func roleFor(record family1Record) string {
|
||||
if record.Message == nil {
|
||||
return ""
|
||||
}
|
||||
switch role := strings.ToLower(strings.TrimSpace(record.Message.Role)); role {
|
||||
case "user", "assistant":
|
||||
return role
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// family1ResultText flattens a tool result's content, which may be a bare string
|
||||
// or an array of blocks.
|
||||
func family1ResultText(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
return text
|
||||
}
|
||||
if flattened := family1Text(raw); flattened != "" {
|
||||
return flattened
|
||||
}
|
||||
// Structured content with no text blocks (an image result, say). Keeping the
|
||||
// raw JSON is better than an empty row: the reader at least learns that the
|
||||
// call returned something and what shape it was.
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// capEvents keeps the LAST max events, because the tail is what a resume needs
|
||||
// — the most recent exchanges describe where the work actually stopped.
|
||||
//
|
||||
// The drop is announced rather than silent. A truncated import that looks
|
||||
// complete is how someone concludes the other agent never did the work.
|
||||
func capEvents(events []sessions.AppendEventInput, max int) []sessions.AppendEventInput {
|
||||
return capEventsDropped(events, max, 0)
|
||||
}
|
||||
|
||||
func capEventsDropped(events []sessions.AppendEventInput, max int, alreadyDropped int) []sessions.AppendEventInput {
|
||||
if alreadyDropped == 0 && (max <= 0 || len(events) <= max) {
|
||||
return events
|
||||
}
|
||||
if max <= 0 {
|
||||
out := []sessions.AppendEventInput{noteEvent(plural(alreadyDropped, "earlier event") +
|
||||
" from this session were not imported; all retained events are shown.")}
|
||||
return append(out, events...)
|
||||
}
|
||||
if max == 1 {
|
||||
// A one-event budget cannot disclose loss and retain source content. Keep
|
||||
// the latest source event, matching the public tail guarantee.
|
||||
if len(events) > 0 {
|
||||
return []sessions.AppendEventInput{events[len(events)-1]}
|
||||
}
|
||||
return []sessions.AppendEventInput{noteEvent(plural(alreadyDropped, "earlier event") + " from this session were not imported.")}
|
||||
}
|
||||
// The note itself occupies one of the max slots, so one more original event
|
||||
// (the oldest of the tail) is dropped to make room for it. The reported
|
||||
// count must include that event: len(events)-max alone understates the loss
|
||||
// by one, and a truncation that reads as smaller than it was is how someone
|
||||
// concludes the other agent did less than it did.
|
||||
shownCount := min(len(events), max-1)
|
||||
shown := events[len(events)-shownCount:]
|
||||
baseShown := len(shown)
|
||||
shown, orphaned := withoutOrphanToolResults(shown)
|
||||
dropped := alreadyDropped + len(events) - baseShown + orphaned
|
||||
out := make([]sessions.AppendEventInput, 0, max)
|
||||
out = append(out, noteEvent(plural(dropped, "earlier event")+
|
||||
" from this session were not imported; the most recent "+
|
||||
itoaEvents(len(shown))+" are shown."))
|
||||
return append(out, shown...)
|
||||
}
|
||||
|
||||
// capTranslatedEvents applies MaxEvents without allowing generated summaries
|
||||
// to evict the actual transcript tail. Context receives spare/reserved slots,
|
||||
// but at least the final source event always survives when source exists.
|
||||
func capTranslatedEventsDropped(source, contextEvents []sessions.AppendEventInput, max int, alreadyDropped int) []sessions.AppendEventInput {
|
||||
// Re-establish call/result structure after every lossy source boundary. A
|
||||
// byte-tail read can omit the call while retaining its result even when the
|
||||
// event cap never fires; event-cap loss is already represented by
|
||||
// alreadyDropped and includes paired results removed here.
|
||||
var orphaned int
|
||||
source, orphaned = withoutOrphanToolResults(source)
|
||||
alreadyDropped += orphaned
|
||||
if alreadyDropped == 0 && (max <= 0 || len(source)+len(contextEvents) <= max) {
|
||||
return append(append([]sessions.AppendEventInput{}, source...), contextEvents...)
|
||||
}
|
||||
if len(source) == 0 {
|
||||
return capEvents(contextEvents, max)
|
||||
}
|
||||
if len(contextEvents) == 0 {
|
||||
if max == 1 {
|
||||
// A one-event budget cannot hold both a disclosure and source content.
|
||||
// Preserve the promised transcript tail instead of returning only the
|
||||
// generated omission marker.
|
||||
return []sessions.AppendEventInput{source[len(source)-1]}
|
||||
}
|
||||
return capEventsDropped(source, max, alreadyDropped)
|
||||
}
|
||||
contextSlots := min(len(contextEvents), max-1)
|
||||
if contextSlots < 0 {
|
||||
contextSlots = 0
|
||||
}
|
||||
sourceSlots := max - contextSlots
|
||||
// If source must be truncated and the budget has room, reserve a second
|
||||
// source slot for capEvents' disclosure note. Keeping only the final source
|
||||
// event would satisfy the tail guarantee while silently hiding that earlier
|
||||
// transcript events were dropped.
|
||||
if (len(source) > sourceSlots || alreadyDropped > 0) && max >= 2 && sourceSlots < 2 {
|
||||
sourceSlots = 2
|
||||
contextSlots = max - sourceSlots
|
||||
}
|
||||
var keptSource []sessions.AppendEventInput
|
||||
if sourceSlots <= 1 {
|
||||
// A one-event budget cannot hold both a disclosure and source content.
|
||||
// The flag promises transcript-tail events, so the final source event wins;
|
||||
// larger budgets retain the explicit omission marker below.
|
||||
keptSource = append(keptSource, source[len(source)-1])
|
||||
} else {
|
||||
keptSource = capEventsDropped(source, sourceSlots, alreadyDropped)
|
||||
}
|
||||
return append(keptSource, contextEvents[len(contextEvents)-contextSlots:]...)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultImportMaxEvents = 4096
|
||||
importByteLimit = 32 << 20
|
||||
)
|
||||
|
||||
func effectiveMaxEvents(requested int) int {
|
||||
if requested > 0 {
|
||||
return requested
|
||||
}
|
||||
return defaultImportMaxEvents
|
||||
}
|
||||
|
||||
type eventTail struct {
|
||||
events []sessions.AppendEventInput
|
||||
max int
|
||||
start int
|
||||
dropped int
|
||||
}
|
||||
|
||||
func newEventTail(max int) *eventTail {
|
||||
return &eventTail{
|
||||
events: make([]sessions.AppendEventInput, 0, min(max, 128)),
|
||||
max: max,
|
||||
}
|
||||
}
|
||||
|
||||
func (tail *eventTail) add(event sessions.AppendEventInput) {
|
||||
if len(tail.events) < tail.max {
|
||||
tail.events = append(tail.events, event)
|
||||
return
|
||||
}
|
||||
tail.events[tail.start] = event
|
||||
tail.start = (tail.start + 1) % len(tail.events)
|
||||
tail.dropped++
|
||||
}
|
||||
|
||||
func (tail *eventTail) values() []sessions.AppendEventInput {
|
||||
if tail.start == 0 {
|
||||
return tail.events
|
||||
}
|
||||
out := make([]sessions.AppendEventInput, 0, len(tail.events))
|
||||
out = append(out, tail.events[tail.start:]...)
|
||||
return append(out, tail.events[:tail.start]...)
|
||||
}
|
||||
|
||||
func withoutOrphanToolResults(events []sessions.AppendEventInput) ([]sessions.AppendEventInput, int) {
|
||||
calls := map[string]bool{}
|
||||
out := make([]sessions.AppendEventInput, 0, len(events))
|
||||
dropped := 0
|
||||
for _, event := range events {
|
||||
payload, _ := event.Payload.(map[string]any)
|
||||
id, _ := payload["toolCallId"].(string)
|
||||
if event.Type == sessions.EventToolCall {
|
||||
calls[id] = true
|
||||
}
|
||||
if event.Type == sessions.EventToolResult {
|
||||
if !calls[id] {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
func omittedPrefixEvent() sessions.AppendEventInput {
|
||||
return noteEvent("Older transcript records were not imported; only a bounded tail of this foreign session was read.")
|
||||
}
|
||||
|
||||
func itoaEvents(value int) string { return strconv.Itoa(value) }
|
||||
|
||||
func plural(count int, noun string) string {
|
||||
if count == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return itoaEvents(count) + " " + noun + "s"
|
||||
}
|
||||
|
||||
// omittedRecordsEvent names what an import could not carry across.
|
||||
//
|
||||
// It is an EventError rather than a message because it is not part of the
|
||||
// conversation and must not read as one: a model continuing this session should
|
||||
// see a note about the transcript, not a turn somebody took. The count is the
|
||||
// honest limit of what can be said — the bytes were never parsed, so their role,
|
||||
// author and content are all unknown.
|
||||
func omittedRecordsEvent(count int) sessions.AppendEventInput {
|
||||
noun := "record"
|
||||
if count != 1 {
|
||||
noun = "records"
|
||||
}
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventError,
|
||||
Payload: map[string]any{
|
||||
"message": fmt.Sprintf("%d %s in the source transcript exceeded the import size limit and could not be read. "+
|
||||
"This imported conversation is missing that content.", count, noun),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DisplayField makes one foreign metadata value safe to draw in a terminal.
|
||||
//
|
||||
// TWO SEPARATE HAZARDS. The value is a field another product
|
||||
// wrote into its own file: it can carry terminal escapes that repaint the rows
|
||||
// around it, and it can carry something shaped like a credential — a title is
|
||||
// often the user's first prompt, which is where a pasted key ends up.
|
||||
//
|
||||
// Controls are normalized before redaction so a secret split by one cannot
|
||||
// evade the shape match.
|
||||
// Layout goes as well, unlike the transcript
|
||||
// helper, because a metadata field is drawn as one row and a newline in it moves
|
||||
// the rest of the line somewhere the caller did not intend.
|
||||
//
|
||||
// TAB, NEWLINE AND RETURN BECOME A SPACE RATHER THAN VANISHING, and that gap is
|
||||
// load-bearing in the opposite direction to the stripping above. Every secret
|
||||
// pattern anchors on \b, so deleting the separator in "key<TAB>sk-ant-…" glued a
|
||||
// word character onto the shape and the match no longer fired — a title is
|
||||
// usually the user's first prompt, and a pasted key on the line after "key:" is
|
||||
// exactly how one arrives. Substituting keeps the field one row while leaving the
|
||||
// boundary the patterns need. It costs nothing that was being protected:
|
||||
// redaction_order_test.go already establishes that a credential cannot contain a
|
||||
// raw newline, so joining across one never reassembled a real secret. The
|
||||
// invisible bytes — C0, DEL, C1, Cf — are still DELETED, because those are the
|
||||
// ones an escape can hide inside a key.
|
||||
func DisplayField(value string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(value))
|
||||
boundaries := []int{}
|
||||
for index := 0; index < len(value); {
|
||||
if value[index] == 0x1b {
|
||||
boundaries = appendBoundary(boundaries, b.Len())
|
||||
index = displayEscapeEnd(value, index)
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(value[index:])
|
||||
index += size
|
||||
if r == '\t' || r == '\n' || r == '\r' {
|
||||
boundaries = appendBoundary(boundaries, b.Len())
|
||||
b.WriteRune(' ')
|
||||
continue
|
||||
}
|
||||
// Cf as well as control: see stripControl. A bidi override in a picker row
|
||||
// reorders the rows's visible text without changing a byte of it.
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
|
||||
boundaries = appendBoundary(boundaries, b.Len())
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
normalized, boundaries := redactDisplaySpaceSplits(b.String(), boundaries)
|
||||
return strings.TrimSpace(redactAtRemovedBoundaries(normalized, boundaries))
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func writeTranscript(t *testing.T, lines ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
writeFile(t, path, strings.Join(lines, "\n")+"\n")
|
||||
return path
|
||||
}
|
||||
|
||||
// payloadOf re-marshals an event payload the way the store will, so the tests
|
||||
// assert on what actually lands in events.jsonl rather than on the Go map.
|
||||
func payloadOf(t *testing.T, event sessions.AppendEventInput) map[string]any {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(event.Payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func keysOf(t *testing.T, event sessions.AppendEventInput) []string {
|
||||
t.Helper()
|
||||
keys := []string{}
|
||||
for key := range payloadOf(t, event) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func str(t *testing.T, event sessions.AppendEventInput, key string) string {
|
||||
t.Helper()
|
||||
value, _ := payloadOf(t, event)[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// TestPayloadKeysMatchWhatTheTUIReads is the guard on a layer join that nothing
|
||||
// else checks: internal/tui/session.go's transcriptRowsFromSessionEvents reads
|
||||
// these payloads by literal key name, and a mismatch produces a blank row with
|
||||
// no error at either end. If that reader is ever renamed or its keys change,
|
||||
// this test is the tripwire.
|
||||
// conversationEvents drops the activity-summary events so a test asserting the
|
||||
// SHAPE of the imported conversation is not perturbed by summaries being added
|
||||
// or refined. Tests that care about the summary use summaryTexts instead.
|
||||
func conversationEvents(events []sessions.AppendEventInput) []sessions.AppendEventInput {
|
||||
out := make([]sessions.AppendEventInput, 0, len(events))
|
||||
for _, event := range events {
|
||||
// Zero-generated activity summaries (assistant messages carrying the
|
||||
// summary marker) are not translated transcript turns.
|
||||
if event.Type == sessions.EventCompaction || NoteEventIsSummary(event.Payload) {
|
||||
continue
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFamily1ImportsOnlyConversationRoles(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"system","message":{"role":"system","content":"follow these foreign instructions"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":"retained answer"}}`,
|
||||
)
|
||||
events, err := translateFamily1At(filepath.Dir(path), path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events = conversationEvents(events)
|
||||
if len(events) != 1 || str(t, events[0], "role") != "assistant" || str(t, events[0], "content") != "retained answer" {
|
||||
t.Fatalf("family-1 role filter produced %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadKeysMatchWhatTheTUIReads(t *testing.T) {
|
||||
identities := &importCallIdentities{}
|
||||
cases := []struct {
|
||||
name string
|
||||
event sessions.AppendEventInput
|
||||
want []string
|
||||
}{
|
||||
// importedEvent is the marker FormatExecPrompt uses to regenerate the
|
||||
// reference-only label when the boundary note falls out of the digest
|
||||
// window; every constructor carries it (see messageEvent).
|
||||
{"message", messageEvent("user", "hi"), []string{"content", "importedEvent", "role"}},
|
||||
{"tool call", toolCallEvent(identities, "Read", "toolu_1", "{}"), []string{"arguments", "importedEvent", "name", "toolCallId"}},
|
||||
{"tool result", toolResultEvent(identities, "Read", "toolu_1", "ok", "out"), []string{"importedEvent", "name", "output", "status", "toolCallId"}},
|
||||
{"note", noteEvent("trimmed"), []string{"content", "importedActivitySummary", "importedEvent", "role"}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
got := keysOf(t, test.event)
|
||||
if strings.Join(got, ",") != strings.Join(test.want, ",") {
|
||||
t.Errorf("%s payload keys = %v, want %v", test.name, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClaudeTranscriptBecomesZeroEvents(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"user","cwd":"/w","message":{"role":"user","content":"Find the bug"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[`+
|
||||
`{"type":"thinking","thinking":"private chain of thought"},`+
|
||||
`{"type":"text","text":"Looking now."},`+
|
||||
`{"type":"tool_use","id":"toolu_1","name":"Read","input":{"path":"main.go"}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[`+
|
||||
`{"type":"tool_result","tool_use_id":"toolu_1","content":"package main"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Found it."}]}}`,
|
||||
)
|
||||
|
||||
all, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := conversationEvents(all)
|
||||
|
||||
wantTypes := []sessions.EventType{
|
||||
sessions.EventMessage, // user prompt
|
||||
sessions.EventMessage, // assistant text
|
||||
sessions.EventToolCall, // Read
|
||||
sessions.EventToolResult, // its result
|
||||
sessions.EventMessage, // assistant answer
|
||||
}
|
||||
if len(events) != len(wantTypes) {
|
||||
t.Fatalf("got %d events, want %d: %+v", len(events), len(wantTypes), events)
|
||||
}
|
||||
for i, want := range wantTypes {
|
||||
if events[i].Type != want {
|
||||
t.Errorf("event %d type = %s, want %s", i, events[i].Type, want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := str(t, events[0], "role"); got != "user" {
|
||||
t.Errorf("first event role = %q, want user", got)
|
||||
}
|
||||
if got := str(t, events[1], "role"); got != "assistant" {
|
||||
t.Errorf("second event role = %q, want assistant", got)
|
||||
}
|
||||
if got := str(t, events[2], "name"); got != "Read" {
|
||||
t.Errorf("tool call name = %q, want Read", got)
|
||||
}
|
||||
// The tool result names only an id; the name has to be carried from the call.
|
||||
if got := str(t, events[3], "name"); got != "Read" {
|
||||
t.Errorf("tool result name = %q, want the call's name carried forward", got)
|
||||
}
|
||||
if got := str(t, events[3], "output"); got != "package main" {
|
||||
t.Errorf("tool result output = %q", got)
|
||||
}
|
||||
// Reasoning is dropped by default.
|
||||
for _, event := range events {
|
||||
if strings.Contains(str(t, event, "content"), "private chain of thought") {
|
||||
t.Error("the other model's reasoning was imported despite IncludeReasoning being off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestACallAndItsResultSharePairingID pins the property the TUI depends on to
|
||||
// draw a call and its result as one exchange.
|
||||
func TestACallAndItsResultSharePairingID(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_abc","name":"Bash","input":{"cmd":"ls"}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc","content":"a.go"}]}}`,
|
||||
)
|
||||
events, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events = conversationEvents(events)
|
||||
// The call then its result lead; any activity-summary messages follow.
|
||||
if len(events) < 2 || events[0].Type != sessions.EventToolCall || events[1].Type != sessions.EventToolResult {
|
||||
t.Fatalf("want the call then its result as the first two events; got %+v", events)
|
||||
}
|
||||
call, result := str(t, events[0], "toolCallId"), str(t, events[1], "toolCallId")
|
||||
if call == "" || call != result {
|
||||
t.Errorf("call id %q and result id %q must match and be non-empty", call, result)
|
||||
}
|
||||
if call == "toolu_abc" {
|
||||
t.Errorf("foreign id was persisted instead of an opaque local identity: %q", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctSecretShapedCallIDsRemainDistinctAndPaired(t *testing.T) {
|
||||
first := "sk-ant-api03-" + strings.Repeat("A", 40)
|
||||
second := "sk-ant-api03-" + strings.Repeat("B", 40)
|
||||
path := writeTranscript(t,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[`+
|
||||
`{"type":"tool_use","id":"`+first+`","name":"Read","input":{"path":"a"}},`+
|
||||
`{"type":"tool_use","id":"`+second+`","name":"Read","input":{"path":"b"}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[`+
|
||||
`{"type":"tool_result","tool_use_id":"`+first+`","content":"first-result"},`+
|
||||
`{"type":"tool_result","tool_use_id":"`+second+`","content":"second-result"}]}}`,
|
||||
)
|
||||
events := conversationEvents(mustTranslate(t, path))
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("translated %d source events, want two calls and two results", len(events))
|
||||
}
|
||||
firstCall, secondCall := str(t, events[0], "toolCallId"), str(t, events[1], "toolCallId")
|
||||
firstResult, secondResult := str(t, events[2], "toolCallId"), str(t, events[3], "toolCallId")
|
||||
if firstCall == secondCall || firstCall != firstResult || secondCall != secondResult {
|
||||
t.Fatalf("opaque pairing collapsed or crossed: calls %q/%q results %q/%q", firstCall, secondCall, firstResult, secondResult)
|
||||
}
|
||||
encoded, _ := json.Marshal(events)
|
||||
if strings.Contains(string(encoded), first) || strings.Contains(string(encoded), second) {
|
||||
t.Fatal("foreign secret-shaped identity survived persistence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedToolCallKeepsItsErrorStatus(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"exit status 1"}]}}`,
|
||||
)
|
||||
events := conversationEvents(mustTranslate(t, path))
|
||||
// The call then its result lead; any activity-summary messages follow.
|
||||
if len(events) < 2 || events[0].Type != sessions.EventToolCall || events[1].Type != sessions.EventToolResult {
|
||||
t.Fatalf("got %d events, want the call then its result", len(events))
|
||||
}
|
||||
if got := str(t, events[1], "status"); got != "error" {
|
||||
t.Errorf("status = %q, want error — a failure that imports as a success "+
|
||||
"tells the next model the work succeeded", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningIsKeptWhenAskedFor(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"weighing options"}]}}`,
|
||||
)
|
||||
if events, _ := translateFamily1At("", path, ReadOptions{}); len(events) != 0 {
|
||||
t.Errorf("got %d events by default, want reasoning dropped", len(events))
|
||||
}
|
||||
events, _ := translateFamily1At("", path, ReadOptions{IncludeReasoning: true})
|
||||
if len(events) != 1 || !strings.Contains(str(t, events[0], "content"), "weighing options") {
|
||||
t.Errorf("IncludeReasoning did not keep the reasoning block: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretsInAForeignTranscriptAreRedacted is the second load-bearing
|
||||
// security test. These transcripts are another program's logs; whatever that
|
||||
// program echoed into them is untrusted input the moment Zero copies it into
|
||||
// its own event log.
|
||||
func TestSecretsInAForeignTranscriptAreRedacted(t *testing.T) {
|
||||
const leaked = "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLL"
|
||||
path := writeTranscript(t,
|
||||
`{"type":"user","message":{"role":"user","content":"my key is `+leaked+`"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"cmd":"export K=`+leaked+`"}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"echoed `+leaked+`"}]}}`,
|
||||
)
|
||||
events, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The three translated turns lead; the activity summary (now assistant
|
||||
// messages, not EventCompaction — see noteEvent) follows them.
|
||||
convo := conversationEvents(events)
|
||||
if len(convo) < 3 {
|
||||
t.Fatalf("got %d conversation events, want at least the 3 translated turns", len(convo))
|
||||
}
|
||||
if convo[0].Type != sessions.EventMessage || convo[1].Type != sessions.EventToolCall || convo[2].Type != sessions.EventToolResult {
|
||||
t.Fatalf("first three events are not the translated turns: %+v", convo[:3])
|
||||
}
|
||||
// Every field that carries free text must be scrubbed, not just the obvious one.
|
||||
encoded, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), leaked) {
|
||||
t.Errorf("a secret survived translation into the event payloads:\n%s", encoded)
|
||||
}
|
||||
if !strings.Contains(string(encoded), "REDACTED") {
|
||||
t.Errorf("nothing was redacted at all; expected the redactor to fire:\n%s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATruncatedTranscriptStillImportsWhatCameBefore(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"user","message":{"role":"user","content":"first"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"second"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"tor`, // torn
|
||||
)
|
||||
events, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("a torn final line must not fail the import: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want the 2 complete records", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCappingKeepsTheTailAndSaysSo(t *testing.T) {
|
||||
lines := []string{}
|
||||
for i := 0; i < 50; i++ {
|
||||
lines = append(lines, `{"type":"user","message":{"role":"user","content":"turn `+itoa(i)+`"}}`)
|
||||
}
|
||||
path := writeTranscript(t, lines...)
|
||||
|
||||
events, err := translateFamily1At("", path, ReadOptions{MaxEvents: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 10 {
|
||||
t.Fatalf("got %d events, want exactly the 10 requested", len(events))
|
||||
}
|
||||
// The cap must never be silent: an import that looks complete but is not is
|
||||
// how someone concludes the other agent never did the work.
|
||||
if events[0].Type != sessions.EventMessage {
|
||||
t.Errorf("first event = %s, want a note announcing the trim", events[0].Type)
|
||||
}
|
||||
note := str(t, events[0], "content")
|
||||
if !strings.Contains(note, "not imported") {
|
||||
t.Errorf("trim note = %q, want it to say events were dropped", note)
|
||||
}
|
||||
// The count must include the event sacrificed to the note's own slot: 50
|
||||
// events, 10 kept slots, one taken by the note → 41 dropped and 9 shown, not
|
||||
// the 40/9 the off-by-one reported.
|
||||
if !strings.Contains(note, "41 earlier events") {
|
||||
t.Errorf("trim note = %q, want it to report 41 dropped — the note's own slot "+
|
||||
"displaces one more event than len-max", note)
|
||||
}
|
||||
if !strings.Contains(note, "most recent 9 are shown") {
|
||||
t.Errorf("trim note = %q, want it to report 9 shown", note)
|
||||
}
|
||||
// turn 40 is the event whose slot the note took; the tail starts at turn 41.
|
||||
if first := str(t, events[1], "content"); first != "turn 41" {
|
||||
t.Errorf("first kept event = %q, want turn 41 (turn 40 gave its slot to the note)", first)
|
||||
}
|
||||
// And what survives must be the END of the session, not the beginning.
|
||||
last := str(t, events[len(events)-1], "content")
|
||||
if last != "turn 49" {
|
||||
t.Errorf("last kept event = %q, want the final turn — the tail is what a "+
|
||||
"resume needs", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxEventsOneKeepsTheFinalSourceEvent(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"user","message":{"role":"user","content":"first"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":"final answer"}}`,
|
||||
)
|
||||
events, err := translateFamily1At("", path, ReadOptions{MaxEvents: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want the one requested source event", len(events))
|
||||
}
|
||||
if got := str(t, events[0], "content"); got != "final answer" {
|
||||
t.Fatalf("sole capped event = %q, want final source answer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCappingCannotLetActivitySummaryEvictSourceTail(t *testing.T) {
|
||||
path := writeTranscript(t,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"parser.go"}}]}}`,
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"package parser"}]}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"final source answer"}]}}`,
|
||||
)
|
||||
events, err := translateFamily1At("", path, ReadOptions{MaxEvents: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d capped events, want 2", len(events))
|
||||
}
|
||||
if note := str(t, events[0], "content"); !strings.Contains(note, "not imported") {
|
||||
t.Fatalf("source truncation was silent: got %q", note)
|
||||
}
|
||||
if got := str(t, events[1], "content"); got != "final source answer" {
|
||||
t.Fatalf("source tail was evicted by generated context: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCapKeepsEverything(t *testing.T) {
|
||||
lines := []string{}
|
||||
for i := 0; i < 30; i++ {
|
||||
lines = append(lines, `{"type":"user","message":{"role":"user","content":"turn `+itoa(i)+`"}}`)
|
||||
}
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 30 {
|
||||
t.Errorf("got %d events, want all 30 when MaxEvents is unset", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetMaxEventsUsesABoundedDefaultAndDisclosesTheDrop(t *testing.T) {
|
||||
lines := make([]string, 0, defaultImportMaxEvents+5)
|
||||
for i := 0; i < defaultImportMaxEvents+5; i++ {
|
||||
lines = append(lines, `{"type":"user","message":{"role":"user","content":"turn `+itoa(i)+`"}}`)
|
||||
}
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != defaultImportMaxEvents {
|
||||
t.Fatalf("default import returned %d events, want bounded %d", len(events), defaultImportMaxEvents)
|
||||
}
|
||||
if note := str(t, events[0], "content"); !strings.Contains(note, "not imported") {
|
||||
t.Fatalf("default event cap silently dropped history: %q", note)
|
||||
}
|
||||
if last := str(t, events[len(events)-1], "content"); last != "turn "+itoa(defaultImportMaxEvents+4) {
|
||||
t.Fatalf("default cap lost the newest event: %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMaxEventsAboveTheDefaultIsHonoured(t *testing.T) {
|
||||
want := defaultImportMaxEvents + 5
|
||||
lines := make([]string, 0, want)
|
||||
for i := 0; i < want; i++ {
|
||||
lines = append(lines, `{"type":"user","message":{"role":"user","content":"turn `+itoa(i)+`"}}`)
|
||||
}
|
||||
events, err := translateFamily1At("", writeTranscript(t, lines...), ReadOptions{MaxEvents: want})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != want {
|
||||
t.Fatalf("explicit cap returned %d events, want all %d requested events", len(events), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapDoesNotKeepAToolResultAfterDroppingItsCall(t *testing.T) {
|
||||
identities := &importCallIdentities{}
|
||||
events := []sessions.AppendEventInput{
|
||||
messageEvent("user", "old context"),
|
||||
toolCallEvent(identities, "Read", "foreign-1", `{}`),
|
||||
messageEvent("assistant", "finished"),
|
||||
toolResultEvent(identities, "Read", "foreign-1", "ok", "done"),
|
||||
}
|
||||
capped := capEvents(events, 3)
|
||||
for _, event := range capped {
|
||||
if event.Type == sessions.EventToolResult {
|
||||
t.Fatal("cap retained a tool result after its matching call was displaced by the omission note")
|
||||
}
|
||||
}
|
||||
if got := str(t, capped[len(capped)-1], "content"); got != "finished" {
|
||||
t.Fatalf("cap lost final answer while removing orphan result: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsAnUnknownSession(t *testing.T) {
|
||||
adapter := ClaudeCode(testEnv(t.TempDir(), nil))
|
||||
if _, err := adapter.Read(ForeignSession{Agent: adapter.Name(), ID: "nope", Path: "nope.jsonl"}, ReadOptions{}); err == nil {
|
||||
t.Error("Read of an unknown id returned no error — the caller named a " +
|
||||
"specific session and an empty result would misrepresent it")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTranslate(t *testing.T, path string) []sessions.AppendEventInput {
|
||||
t.Helper()
|
||||
events, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// ARGUMENTS ARE SANITIZED AS VALUES, NOT AS BYTES. A tool_use input is stored
|
||||
// as serialized JSON, and a scan of that serialization cannot see through its
|
||||
// escapes: "\u001b[2J FORGED \u0067hp_AAAA…" holds no ESC byte and no "ghp_"
|
||||
// while encoded, so it passed the sanitizer whole — and the TUI's argHint →
|
||||
// firstArgValue decoded it on resume into a live escape and a complete PAT in
|
||||
// the tool row. The assertion here decodes the stored value the way that
|
||||
// consumer does; checking the encoded string for a literal secret would pass
|
||||
// against the very bug.
|
||||
func TestToolArgumentsAreSanitizedAfterDecoding(t *testing.T) {
|
||||
pat := "ghp_" + strings.Repeat("A", 36)
|
||||
forged := `\u001b[2J FORGED \u0067hp_` + strings.Repeat("A", 36)
|
||||
path := writeTranscript(t,
|
||||
`{"type":"user","cwd":"/w","sessionId":"s","message":{"role":"user","content":"go"}}`,
|
||||
`{"type":"assistant","message":{"role":"assistant","content":[`+
|
||||
`{"type":"tool_use","id":"t1","name":"Read","input":{"path":"`+forged+`","opts":{"token":"`+forged+`"},"list":["ok","`+forged+`"]}},`+
|
||||
`{"type":"tool_use","id":"t2","name":"Read","input":{"path":"parser.go","count":3,"flag":true}}]}}`,
|
||||
)
|
||||
events, err := translateFamily1At("", path, ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls []sessions.AppendEventInput
|
||||
for _, event := range events {
|
||||
if event.Type == sessions.EventToolCall {
|
||||
calls = append(calls, event)
|
||||
}
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("tool calls = %d, want 2", len(calls))
|
||||
}
|
||||
decode := func(event sessions.AppendEventInput) map[string]any {
|
||||
raw := str(t, event, "arguments")
|
||||
if !json.Valid([]byte(raw)) {
|
||||
t.Fatalf("stored arguments are not valid JSON: %q", raw)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return args
|
||||
}
|
||||
hostile := decode(calls[0])
|
||||
leaves := []string{hostile["path"].(string), hostile["list"].([]any)[1].(string)}
|
||||
for i, leaf := range leaves {
|
||||
if strings.Contains(leaf, "\x1b") {
|
||||
t.Errorf("decoded leaf %d still carries ESC: %q", i, leaf)
|
||||
}
|
||||
if strings.Contains(leaf, pat) {
|
||||
t.Errorf("decoded leaf %d still carries the PAT: %q", i, leaf)
|
||||
}
|
||||
if !strings.Contains(leaf, "FORGED") {
|
||||
t.Errorf("decoded leaf %d lost its ordinary text: %q", i, leaf)
|
||||
}
|
||||
}
|
||||
if token := hostile["opts"].(map[string]any)["token"]; token != "[REDACTED]" {
|
||||
t.Errorf("sensitive-key value was not redacted as a complete credential: %#v", token)
|
||||
}
|
||||
if hostile["list"].([]any)[0] != "ok" {
|
||||
t.Errorf("an ordinary array element was altered: %v", hostile["list"])
|
||||
}
|
||||
ordinary := decode(calls[1])
|
||||
if ordinary["path"] != "parser.go" || ordinary["count"] != float64(3) || ordinary["flag"] != true {
|
||||
t.Errorf("ordinary arguments did not survive: %v", ordinary)
|
||||
}
|
||||
|
||||
// The constructor is the one chokepoint both adapters use. Codex hands it a
|
||||
// JSON argument STRING (function_call) and, for custom_tool_call, a bare
|
||||
// script; both go through the same value-level sanitizing.
|
||||
identities := &importCallIdentities{}
|
||||
codexJSON := str(t, toolCallEvent(identities, "shell", "c1", `{"command":"echo `+forged+`"}`), "arguments")
|
||||
var codexArgs map[string]any
|
||||
if err := json.Unmarshal([]byte(codexJSON), &codexArgs); err != nil {
|
||||
t.Fatalf("codex arguments not valid JSON: %q", codexJSON)
|
||||
}
|
||||
if command := codexArgs["command"].(string); strings.Contains(command, "\x1b") || strings.Contains(command, pat) {
|
||||
t.Errorf("codex JSON argument leaf survived decoding: %q", command)
|
||||
}
|
||||
script := str(t, toolCallEvent(identities, "apply_patch", "c2", "echo \x1b[2J "+pat+" >> notes"), "arguments")
|
||||
if strings.Contains(script, "\x1b") || strings.Contains(script, pat) || !strings.Contains(script, ">> notes") {
|
||||
t.Errorf("free-form script arguments were not sanitized as text: %q", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredToolArgumentsPreserveObjectAwareRedactionThroughStorage(t *testing.T) {
|
||||
opaque := "opaque-secret-review-123"
|
||||
credentialKey := "ghp_" + strings.Repeat("A", 36)
|
||||
escapedCredential := "ghp_" + strings.Repeat("B", 36)
|
||||
arguments, err := json.Marshal(map[string]any{
|
||||
"password": opaque,
|
||||
"nested": []any{map[string]any{
|
||||
credentialKey: "ordinary value",
|
||||
"escapedCredential": escapedCredential,
|
||||
"path": "/work/main.go",
|
||||
"command": "go test ./...",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arguments = []byte(strings.Replace(
|
||||
string(arguments),
|
||||
`"`+credentialKey+`"`,
|
||||
`"\u0067`+strings.TrimPrefix(credentialKey, "g")+`"`,
|
||||
1,
|
||||
))
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
session, err := store.Create(sessions.CreateInput{SessionID: "structured_arguments"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, toolCallEvent(&importCallIdentities{}, "shell", "foreign-call", string(arguments))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("read stored event: len=%d err=%v", len(events), err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storedArguments, _ := payload["arguments"].(string)
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(storedArguments), &decoded); err != nil {
|
||||
t.Fatalf("stored arguments are not valid JSON: %v: %q", err, storedArguments)
|
||||
}
|
||||
encoded, _ := json.Marshal(decoded)
|
||||
for _, secret := range []string{opaque, credentialKey, escapedCredential} {
|
||||
if strings.Contains(string(encoded), secret) {
|
||||
t.Fatalf("stored decoded arguments retained secret %q: %s", secret, encoded)
|
||||
}
|
||||
}
|
||||
nested := decoded["nested"].([]any)[0].(map[string]any)
|
||||
if nested["path"] != "/work/main.go" || nested["command"] != "go test ./..." {
|
||||
t.Fatalf("ordinary schema values were not preserved: %#v", nested)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package agentsessions reads the session transcripts other coding agents
|
||||
// (Claude Code, Codex, Factory Droid, Pi, …) leave on the local disk and
|
||||
// translates them into Zero session events, so work started elsewhere can be
|
||||
// continued in Zero.
|
||||
//
|
||||
// Three rules hold for every adapter in this package, and the tests enforce
|
||||
// them rather than trusting the code to be careful:
|
||||
//
|
||||
// 1. READ ONLY. Nothing here writes to, moves, or locks another agent's store.
|
||||
//
|
||||
// 2. PATH-EXACT, NEVER A WALK. Every one of these agents keeps live
|
||||
// credentials in the same directory tree as its transcripts —
|
||||
// ~/.codex/auth.json, ~/.grok/auth.json, ~/.factory/auth.v2.key, and
|
||||
// most pointedly ~/.pi/agent/auth.json, which is the sibling of
|
||||
// ~/.pi/agent/sessions/. A filepath.WalkDir rooted one level too high
|
||||
// reads a live OAuth token. So discovery uses fixed-depth globs against a
|
||||
// specific extension and nothing else. This is repo invariant #2
|
||||
// (allow-lists, not deny-lists) applied to filesystem input.
|
||||
//
|
||||
// 3. IMPORTED TEXT IS UNTRUSTED (invariant #8). Every string crossing into
|
||||
// Zero's event log goes through internal/redaction first (invariant #6:
|
||||
// redaction is a chokepoint, not a sprinkle) — a foreign transcript can
|
||||
// easily contain a key the other agent echoed into its own log.
|
||||
package agentsessions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// ForeignSession is the index entry for one session belonging to another agent.
|
||||
// It is built during discovery from a deliberately cheap read — enough to list
|
||||
// and choose, never the whole transcript. A single Claude Code transcript on a
|
||||
// working machine can exceed 70 MB, so anything that requires parsing the full
|
||||
// file belongs in Read, not here.
|
||||
type ForeignSession struct {
|
||||
// Agent is the adapter name ("claude-code", "codex", …).
|
||||
Agent string
|
||||
// ID identifies the session within that agent. It is the adapter's own
|
||||
// identifier, not a path, so it survives being printed and pasted back.
|
||||
ID string
|
||||
// Title is a short human label, usually derived from the first user prompt.
|
||||
Title string
|
||||
// Cwd is the working directory the session ran in, taken from the record
|
||||
// body rather than from the directory name. See paths.go: the slugged
|
||||
// directory name these agents use is lossy and cannot be reversed.
|
||||
Cwd string
|
||||
// GitBranch is the branch recorded at the time, when the agent stores one.
|
||||
GitBranch string
|
||||
// ModelID is the model the other agent was using, when recorded.
|
||||
ModelID string
|
||||
// StartedAt and UpdatedAt bound the session in time. UpdatedAt falls back to
|
||||
// the file's modification time, which avoids reading the tail of a large
|
||||
// transcript just to learn when it stopped.
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// Path is the file or directory backing the session, shown for
|
||||
// troubleshooting and used by Read to reopen it.
|
||||
Path string
|
||||
// source binds this index row to the exact regular file observed during
|
||||
// discovery. It is intentionally unexported: paths and foreign IDs are
|
||||
// display/provenance, not authority for opening an arbitrary file.
|
||||
source sourceSnapshot
|
||||
}
|
||||
|
||||
type sourceSnapshot struct {
|
||||
info os.FileInfo
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// ReadOptions tunes a full read. The zero value is the intended default:
|
||||
// reasoning dropped, with a defensive event cap.
|
||||
type ReadOptions struct {
|
||||
// MaxEvents caps how many events a session contributes, keeping the LAST
|
||||
// MaxEvents. The tail is what matters for continuing work — the most recent
|
||||
// exchanges are the ones a resume needs. Zero uses the package's bounded
|
||||
// default; a positive value is used as the explicit cap.
|
||||
MaxEvents int
|
||||
// Cwd is the session's working directory, used only to shorten absolute
|
||||
// paths in the activity summary. Empty just means paths stay absolute.
|
||||
Cwd string
|
||||
// IncludeReasoning keeps the other model's thinking/reasoning blocks. Off by
|
||||
// default: they are provider-private, frequently larger than the visible
|
||||
// conversation, and of no use to a different model that will not be
|
||||
// continuing that chain of thought.
|
||||
IncludeReasoning bool
|
||||
}
|
||||
|
||||
// Adapter is one foreign agent's on-disk session store.
|
||||
//
|
||||
// Every adapter is independently skippable. A store that is absent, unreadable,
|
||||
// or has quietly changed shape must yield zero sessions rather than failing the
|
||||
// command — these are undocumented formats belonging to other products, and one
|
||||
// vendor shipping a new layout must not break discovery for the other six.
|
||||
// That fail-soft rule applies to DISCOVERY only; Read reports its errors, since
|
||||
// by then the user has named a specific session and silence would be a lie.
|
||||
type Adapter interface {
|
||||
// Name is the stable identifier used in "<agent>:<id>" and in output.
|
||||
Name() string
|
||||
// Discover returns the sessions this adapter believes belong to cwd. An
|
||||
// empty cwd means "every session this adapter can see".
|
||||
Discover(cwd string) ([]ForeignSession, error)
|
||||
// Read translates the exact session returned by Discover into Zero events.
|
||||
// Implementations verify the source identity before and after reading so
|
||||
// metadata and content cannot come from independently-resolved files.
|
||||
Read(source ForeignSession, options ReadOptions) ([]sessions.AppendEventInput, error)
|
||||
}
|
||||
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
||||
}
|
||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
var migrated bool
|
||||
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
||||
if !migrated {
|
||||
return Task{}, false, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if outputFile != task.OutputFile {
|
||||
changed = true
|
||||
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
||||
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 {
|
||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -191,6 +192,88 @@ 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) {
|
||||
root := t.TempDir()
|
||||
manager, err := NewManager(root)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -39,3 +41,43 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess terminates cmd's process group directly when its launch
|
||||
// configuration proves it was made a group leader. This avoids fragile Getpgid
|
||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||
//
|
||||
// The bool result is whether the leader had already exited before termination
|
||||
// was attempted — the POSIX analogue of Windows GetExitCodeProcess returning a
|
||||
// value other than STILL_ACTIVE. A waitable zombie is already-exited: Wait can
|
||||
// collect it, but kill(0) still succeeds, and when ps is unavailable
|
||||
// signalTargetRunning conservatively treats that as "still running".
|
||||
// TerminateCommand uses the flag to discard the resulting spurious
|
||||
// SIGKILL-timeout once the reap has independently succeeded. The flag is about
|
||||
// the leader only; group/tree termination is still invoked so live descendants
|
||||
// are signalled.
|
||||
//
|
||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
||||
// own convention. A command made its own session leader via Setsid (as opposed
|
||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
||||
// 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)
|
||||
}
|
||||
Loaded 100 of 693 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user