mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b677cf383 | ||
|
|
27962e7d95 | ||
|
|
bea5daa725 | ||
|
|
6dd0b063e8 | ||
|
|
96f850438c | ||
|
|
2edf7fdae0 | ||
|
|
f8bef2ac7d | ||
|
|
c92b7b528c | ||
|
|
da6edfcc6f | ||
|
|
f2b3f40901 | ||
|
|
4b00b175e5 | ||
|
|
dfbc998926 | ||
|
|
8dd8666194 | ||
|
|
82f39271ff | ||
|
|
9cbe684a5a | ||
|
|
3a5b4d9fef | ||
|
|
1c17637267 | ||
|
|
ead3fec4fd | ||
|
|
2dcc7fd5a8 | ||
|
|
d23d347897 | ||
|
|
85833b9c07 | ||
|
|
279ec453fe | ||
|
|
7f445eafaa | ||
|
|
36c293cd80 | ||
|
|
8b6fa8c452 | ||
|
|
a7b5a0487e | ||
|
|
d2d37c47ed | ||
|
|
56667add01 | ||
|
|
74fa880ef3 | ||
|
|
7a75155823 | ||
|
|
92fa78cbc3 | ||
|
|
4facd0d6bd | ||
|
|
bc1add0b86 | ||
|
|
9d00a3716d | ||
|
|
221a7645b6 | ||
|
|
d2f0030732 | ||
|
|
48812e90f1 | ||
|
|
7ae4219dcc | ||
|
|
4a4426634b | ||
|
|
255189876b | ||
|
|
10eb314bdb | ||
|
|
808abb9f3a | ||
|
|
4f54c51eef | ||
|
|
89f52f1b6e | ||
|
|
f50babf8a5 | ||
|
|
2f1302b449 | ||
|
|
c0fecd8779 | ||
|
|
0645b0e8e3 | ||
|
|
72f0210bb4 | ||
|
|
69984b1bca | ||
|
|
072cf44246 | ||
|
|
9a38adde4d | ||
|
|
d6f196ac94 | ||
|
|
59df72bde9 | ||
|
|
72a0bf225e | ||
|
|
72382df0e9 | ||
|
|
1192fad2f5 | ||
|
|
1437d5a24a | ||
|
|
6db192b8e7 | ||
|
|
7932bc1e46 | ||
|
|
a1fecd3aa0 | ||
|
|
a84f2aeb03 | ||
|
|
b96f152a07 | ||
|
|
09ce7df436 | ||
|
|
3eda5346a9 | ||
|
|
d580bb38d5 | ||
|
|
068f35dc77 | ||
|
|
dce3b3a70e | ||
|
|
e38623e255 | ||
|
|
28d0ab8677 | ||
|
|
a0337caf9b | ||
|
|
203ab34a81 | ||
|
|
bb0dd15e1b | ||
|
|
387cc22517 | ||
|
|
1e408f5e33 | ||
|
|
252ff75f17 | ||
|
|
a6f96dc2e4 | ||
|
|
c79a15de28 | ||
|
|
395aa3d799 | ||
|
|
52cc819466 | ||
|
|
67bd9f19f1 | ||
|
|
bd33d5138c | ||
|
|
790d06c4a3 | ||
|
|
5ceab67371 | ||
|
|
909bea3618 | ||
|
|
82a9d866d6 | ||
|
|
e4afe93738 | ||
|
|
89b7555861 | ||
|
|
3b723f8f37 | ||
|
|
4ce57642ae | ||
|
|
95f592a53d | ||
|
|
5fe20ebda8 | ||
|
|
4da3f3e488 | ||
|
|
c69b4d9540 | ||
|
|
4d1998b0ca | ||
|
|
3d2ed10742 | ||
|
|
f4591d7c1c | ||
|
|
895972d792 | ||
|
|
190a52283d | ||
|
|
7f9491966c | ||
|
|
97c746625a | ||
|
|
0f4e3f3bb8 | ||
|
|
b922cb2f64 | ||
|
|
53138c7658 | ||
|
|
b1c357812d | ||
|
|
345212f7fb | ||
|
|
d60c52e5c7 | ||
|
|
d58238782c | ||
|
|
aabf833416 | ||
|
|
2d1b53d7be | ||
|
|
4ed24c6bf4 | ||
|
|
cf9afb9d9e | ||
|
|
7378f0ff1f | ||
|
|
1aebd4fcdf | ||
|
|
9f43d13f5e | ||
|
|
22608aa2c7 | ||
|
|
b9beebd5bf | ||
|
|
082e188198 | ||
|
|
e155699904 | ||
|
|
1f46e71c69 | ||
|
|
8d4cfad768 | ||
|
|
7af507b2c3 | ||
|
|
6bb5e61b7f | ||
|
|
adadf64fa1 | ||
|
|
d61dc916c8 | ||
|
|
d1a3cc999c | ||
|
|
97bc4488b5 | ||
|
|
9c7c87e964 | ||
|
|
1e7a44102c | ||
|
|
3211ad62a9 | ||
|
|
4f3bfa9030 | ||
|
|
1aac234fc6 | ||
|
|
caaead1cd9 | ||
|
|
075b961254 | ||
|
|
8ea1c4905b | ||
|
|
cce4bfa2bb | ||
|
|
8ab68bf0fc | ||
|
|
6fae377e2f | ||
|
|
5ac17b55f0 | ||
|
|
4065a6218b | ||
|
|
c33c372a2f | ||
|
|
1054c188d3 | ||
|
|
bfe416b706 | ||
|
|
728d4af4ce | ||
|
|
df181876f3 | ||
|
|
7ea789d93b | ||
|
|
34a337ac0f | ||
|
|
1cc80b8100 | ||
|
|
4c6dc74500 | ||
|
|
7e2d115df3 | ||
|
|
a8e9421bf9 | ||
|
|
88598c8fc3 | ||
|
|
e6d574587a | ||
|
|
98a1fb29a0 | ||
|
|
aa503bca85 | ||
|
|
98166cbc60 | ||
|
|
bd045b0dfd | ||
|
|
0bf3c44a11 | ||
|
|
faaa43d459 | ||
|
|
a238ace545 | ||
|
|
ab25819640 | ||
|
|
5c18d06e1e | ||
|
|
36c9a44728 | ||
|
|
b9612efc5f | ||
|
|
9d1925ee6e | ||
|
|
39bfc0b045 | ||
|
|
2ecc3bbee0 | ||
|
|
ece9c41f8f | ||
|
|
1071bc41e7 | ||
|
|
52f1b7535e | ||
|
|
f541bd353a | ||
|
|
a7582e3d20 | ||
|
|
c146456d8a |
No files matched your search
@@ -48,6 +48,7 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os != 'Windows'
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -55,6 +56,19 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./...
|
||||
|
||||
race:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -9,7 +9,7 @@ name: Release desktop
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
|
||||
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
|
||||
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
|
||||
@@ -269,8 +269,14 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Keep the repository homepage focused on the installable desktop app;
|
||||
# the CLI release line is configured not to claim repository-wide latest.
|
||||
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
|
||||
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
|
||||
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
args+=(--prerelease --latest=false)
|
||||
else
|
||||
args+=(--latest)
|
||||
fi
|
||||
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
|
||||
|
||||
- name: Upload canary dist for mirror
|
||||
@@ -367,6 +373,10 @@ jobs:
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
fi
|
||||
|
||||
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
|
||||
# protection), so smoke the mirrored objects over the authenticated S3 API
|
||||
# instead of the public edge. This verifies the mirror landed; the public
|
||||
# edge itself is not reachable from CI and is covered by end users' traffic.
|
||||
- name: Smoke desktop release pointers
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
@@ -374,8 +384,11 @@ jobs:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
f=assets/latest.json
|
||||
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
|
||||
.version == $version and
|
||||
@@ -384,7 +397,8 @@ jobs:
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
|
||||
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
|
||||
if [ "$CHANNEL" = "canary" ]; then
|
||||
pointer="canary"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
@@ -393,14 +407,62 @@ jobs:
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
|
||||
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
|
||||
fi
|
||||
|
||||
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
|
||||
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
|
||||
key="${asset#https://dl.reasonix.io/}"
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
|
||||
done
|
||||
|
||||
# Best-effort probe of the release gateway — the updater's second
|
||||
# manifest source — over the same public edge and Go client UA end users
|
||||
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
|
||||
# datacenter/proxy egress gets blocked before the worker runs) and must
|
||||
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
|
||||
# lands; it is surfaced as a warning so the run shows whether the edge
|
||||
# is open. Anything else unexpected (404, 5xx, wrong version) means the
|
||||
# gateway route or pointer regressed and fails hard.
|
||||
- name: Probe public release gateway
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "prerelease — no pointer moved; skipping gateway probe"
|
||||
exit 0
|
||||
fi
|
||||
chan="stable"
|
||||
[ "$CHANNEL" = "canary" ] && chan="canary"
|
||||
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
|
||||
# curl already prints 000 for a transport failure; || true keeps -e
|
||||
# from killing the step so the case below can route it.
|
||||
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
|
||||
case "$code" in
|
||||
200)
|
||||
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
|
||||
echo "gateway serves $VERSION on $chan"
|
||||
else
|
||||
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
403)
|
||||
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
|
||||
;;
|
||||
000|"")
|
||||
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
|
||||
;;
|
||||
*)
|
||||
echo "::error::gateway returned $code for $url — route or pointer regression"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Attach desktop manifest to matching CLI release
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
|
||||
@@ -6,16 +6,17 @@ name: Release npm
|
||||
# own tag lets a prerelease ship to npm's `next` channel without touching the
|
||||
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
|
||||
# its rc). Bump with:
|
||||
# git tag npm-vX.Y.Z # stable -> publishes under `next`
|
||||
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
|
||||
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
|
||||
# git push origin <tag>
|
||||
#
|
||||
# A manual workflow_dispatch can publish either an opt-in canary from the current
|
||||
# ref or an approved stable/next version when tag creation is restricted. Canary
|
||||
# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves
|
||||
# `next`/`latest`. build.mjs decides the dist-tag: 0.x stable is `latest`; a
|
||||
# `-canary.` build is `canary`; the 1.x line and rc prereleases are `next`. Promote
|
||||
# a 1.x stable to the default install with `npm dist-tag add [email protected] latest`.
|
||||
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
|
||||
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
|
||||
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
|
||||
# `npm update -g` users). The verify step below asserts the tag actually landed.
|
||||
on:
|
||||
push:
|
||||
tags: ['npm-v*']
|
||||
@@ -86,3 +87,28 @@ jobs:
|
||||
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
|
||||
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
|
||||
# to `next`, and nothing noticed until users got downgraded. The registry
|
||||
# applies tags asynchronously after publish, hence the poll.
|
||||
- name: Verify dist-tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ steps.ver.outputs.arg }}"
|
||||
version="${version#npm-}"; version="${version#v}"
|
||||
case "$version" in
|
||||
*-canary.*) tag=canary ;;
|
||||
*-*) tag=next ;;
|
||||
*) tag=latest ;;
|
||||
esac
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
|
||||
if [ "$got" = "$version" ]; then
|
||||
echo "dist-tag $tag -> $got"
|
||||
exit 0
|
||||
fi
|
||||
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::npm dist-tag '$tag' never landed on $version"
|
||||
exit 1
|
||||
@@ -30,7 +30,9 @@ jobs:
|
||||
name: archives + checksums + homebrew tap
|
||||
needs: cache-guard
|
||||
runs-on: ubuntu-latest
|
||||
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
|
||||
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
|
||||
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
|
||||
# repository homepage points users at the installable app. Gated on the
|
||||
# `release` environment so only esengine can approve it going public.
|
||||
environment: release
|
||||
steps:
|
||||
@@ -53,6 +55,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
@@ -61,10 +66,108 @@ jobs:
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
fi
|
||||
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
|
||||
# protection), so read the manifest over the authenticated S3 API instead
|
||||
# of the public edge.
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set region auto
|
||||
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
|
||||
jq -e '
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
|
||||
# The compatibility asset exists for pre-v1.16 desktop updaters that
|
||||
# still poll GitHub's repository-wide latest URL. Desktop releases now
|
||||
# own that Latest badge, but this check still exercises the public fallback
|
||||
# path exactly the way those clients fetch it: anonymously, over the public
|
||||
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
|
||||
# 403s Actions egress — see the R2 note above), GitHub serves its own
|
||||
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
|
||||
# for weeks precisely because nothing exercised the public path. Retries
|
||||
# cover the release CDN propagating the freshly uploaded asset.
|
||||
- name: Smoke public compatibility manifest
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
fi
|
||||
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
|
||||
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
|
||||
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt $attempt failed; retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::public compatibility manifest unreachable at $url"
|
||||
exit 1
|
||||
|
||||
# A stable CLI release must never leave the npm line behind: v1.17.5
|
||||
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
|
||||
# (#5822) — every `npm update -g` user was silently downgraded to a
|
||||
# months-old version, and nothing noticed because the npm line
|
||||
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
|
||||
# stable npm tag was simply never pushed. release-npm.yml's own verify
|
||||
# step only guards runs that happen; this guard catches the run that
|
||||
# DIDN'T.
|
||||
#
|
||||
# Two distinct states, two responses (the runs race: both workflows wait
|
||||
# on the release environment independently, and npm dist-tags propagate
|
||||
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
|
||||
# mid-release state, not a failure):
|
||||
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
|
||||
# nobody pushed the npm release at all.
|
||||
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
|
||||
# The npm run may still be awaiting its own environment approval;
|
||||
# release-npm.yml's verify step owns asserting the dist-tag lands.
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
version="${TAG#v}"
|
||||
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
|
||||
if [ "$newest" = "$got" ]; then
|
||||
echo "npm latest -> $got (>= $version) OK"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
|
||||
exit 0
|
||||
@@ -58,4 +58,5 @@ homebrew_casks:
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
make_latest: false
|
||||
name_template: "Reasonix CLI {{ .Tag }}"
|
||||
@@ -63,6 +63,37 @@ make hooks # install git hooks (pre-push: go vet)
|
||||
make cross # cross-compile for all 6 targets
|
||||
```
|
||||
|
||||
### Isolated development environment
|
||||
|
||||
A source-built binary shares no on-disk state with a stable release when launched
|
||||
with `REASONIX_HOME` set. This gives each build its own self-contained directory
|
||||
tree — config, credentials, sessions, cache, skills, commands, hooks, and
|
||||
desktop tab state — so the two builds never interfere:
|
||||
|
||||
**CLI**
|
||||
|
||||
```bash
|
||||
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
|
||||
# or after building:
|
||||
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
|
||||
```
|
||||
|
||||
**Desktop**
|
||||
|
||||
```bash
|
||||
cd desktop && wails build
|
||||
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
|
||||
```
|
||||
|
||||
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
|
||||
Command Prompt; the binary extension is `.exe`.
|
||||
|
||||
The directory is empty on first launch; the app behaves exactly like a fresh
|
||||
install. Every subsequent write — config saves, credential storage, session
|
||||
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
|
||||
directory scanning, and all other fallback paths are skipped so no production
|
||||
data leaks in or out.
|
||||
|
||||
### Cache-first review gate
|
||||
|
||||
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
|
||||
|
||||
@@ -131,6 +131,8 @@ commands, `@` references, and two-model setup are all in the
|
||||
from the desktop app, then use approvals, YOLO, and commands from IM.
|
||||
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
|
||||
data types, and roadmap.
|
||||
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
|
||||
complex requests with context, output boundaries, constraints, and when to ask.
|
||||
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
|
||||
names, read-only flags, and schema snapshot guard.
|
||||
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
|
||||
|
||||
@@ -120,6 +120,7 @@ provider key 的运行时 fallback,但仍会作为当前 workspace 范围内
|
||||
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
|
||||
Bot,以及 IM 里的审批、YOLO 和命令交互。
|
||||
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
|
||||
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
|
||||
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
|
||||
read-only 标记和 schema 快照保护。
|
||||
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
|
||||
|
||||
+6
-5
@@ -119,9 +119,10 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
|
||||
and attaches the current desktop manifest to the matching CLI release for old
|
||||
clients that still ask GitHub's repository-wide `latest` release for it.
|
||||
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
|
||||
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
|
||||
current desktop manifest to the matching CLI release for old clients that still
|
||||
ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
@@ -133,8 +134,8 @@ The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
|
||||
the CLI release line. Self-update behavior by platform:
|
||||
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
|
||||
not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
|
||||
+211
-25
@@ -111,6 +111,14 @@ type App struct {
|
||||
activeTabID string
|
||||
readyHook func()
|
||||
|
||||
// tabsRestored is closed when restoreOrBuildTabs has finished populating
|
||||
// a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
|
||||
// work that inspects "which sessions are open" or persists the tab list
|
||||
// (recovery GC's DeleteSession does both) must wait on it: running against
|
||||
// the pre-restore empty tab map would treat every saved tab's session as
|
||||
// closed and could overwrite desktop-tabs.json with an empty snapshot.
|
||||
tabsRestored chan struct{}
|
||||
|
||||
// projectTreeChangedHook is test-only: set once before any concurrency
|
||||
// starts, then read lock-free from emitProjectTreeChanged (whose callers
|
||||
// may or may not hold a.mu, so it cannot re-lock). Never write it after
|
||||
@@ -397,11 +405,17 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.heartbeat = newHeartbeatEngine(a)
|
||||
a.heartbeat.Start()
|
||||
|
||||
a.mu.Lock()
|
||||
a.tabsRestored = make(chan struct{})
|
||||
a.mu.Unlock()
|
||||
go a.restoreOrBuildTabs()
|
||||
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
|
||||
a.goSafe("sendStartupPing", a.sendStartupPing)
|
||||
a.goSafe("flushMetrics", a.flushMetrics)
|
||||
a.goSafe("flushPendingCrash", a.flushPendingCrash)
|
||||
// After restoreOrBuildTabs is launched: the GC's first sweep waits on
|
||||
// tabsRestored so it never observes the pre-restore empty tab map.
|
||||
a.startRecoveryGC()
|
||||
}
|
||||
|
||||
func (a *App) beforeClose(ctx context.Context) bool {
|
||||
@@ -478,9 +492,38 @@ func (a *App) trayReadySignal() <-chan struct{} {
|
||||
return a.tray.ready
|
||||
}
|
||||
|
||||
// markTabsRestored closes the tabsRestored gate exactly once. Safe when the
|
||||
// channel was never created (tests that drive App without startup).
|
||||
func (a *App) markTabsRestored() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.tabsRestored == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-a.tabsRestored:
|
||||
default:
|
||||
close(a.tabsRestored)
|
||||
}
|
||||
}
|
||||
|
||||
// tabsRestoredSignal returns a channel closed once tab restore has completed.
|
||||
// When startup never armed the gate (tests), it reports already-restored.
|
||||
func (a *App) tabsRestoredSignal() <-chan struct{} {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.tabsRestored == nil {
|
||||
closed := make(chan struct{})
|
||||
close(closed)
|
||||
return closed
|
||||
}
|
||||
return a.tabsRestored
|
||||
}
|
||||
|
||||
func (a *App) showMainWindow() {
|
||||
if a.ctx != nil {
|
||||
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
|
||||
a.kickDeferredRebuildRetry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +589,9 @@ func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
|
||||
// default Global tab on first launch.
|
||||
func (a *App) restoreOrBuildTabs() {
|
||||
defer a.recoverToPending("restoreOrBuildTabs")
|
||||
// Unblock startup work gated on the restore (recovery GC) no matter how
|
||||
// this returns — including the recover path above.
|
||||
defer a.markTabsRestored()
|
||||
// Reap any orphaned codegraph processes from a previous crash or older
|
||||
// version that leaked them, so they don't accumulate across restarts.
|
||||
a.reapOrphanCodeGraph()
|
||||
@@ -558,7 +604,7 @@ func (a *App) restoreOrBuildTabs() {
|
||||
_, _ = config.MigrateLegacyIfNeeded()
|
||||
f := loadTabsFile()
|
||||
_, _ = recoverLegacyProjectSidebarRoots(f)
|
||||
_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())
|
||||
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
|
||||
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
|
||||
|
||||
// Load i18n from the first available config.
|
||||
@@ -594,7 +640,14 @@ func (a *App) restoreOrBuildTabs() {
|
||||
tab.effort = cloneStringPtr(entry.Effort)
|
||||
tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
|
||||
tab.mode = persistedTabMode(entry.Mode)
|
||||
tab.goal = strings.TrimSpace(entry.Goal)
|
||||
// Validate the persisted goal against the session's goal-state
|
||||
// sidecar: a typed /new or /clear rotates the session through the
|
||||
// controller without passing App.NewSession/ClearSession, so
|
||||
// entry.Goal can be stale. Session rotation writes a stopped
|
||||
// goal-state onto the fresh path; reading it here stops a restart
|
||||
// from re-seeding the cleared goal into the rotated session. A
|
||||
// session without a sidecar keeps the persisted goal (legacy).
|
||||
tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
|
||||
tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
|
||||
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
|
||||
tab.toolApprovalMode = control.ToolApprovalYolo
|
||||
@@ -938,12 +991,23 @@ func (a *App) tabReadOnly(tabID string) bool {
|
||||
|
||||
func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
tab := a.tabByIDLocked(tabID)
|
||||
if tab == nil {
|
||||
a.mu.RUnlock()
|
||||
return nil, nil
|
||||
}
|
||||
return tab, tab.Ctrl
|
||||
ctrl := tab.Ctrl
|
||||
retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
|
||||
a.mu.RUnlock()
|
||||
if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.tabs[tab.ID] != tab {
|
||||
return nil, nil
|
||||
}
|
||||
return tab, tab.Ctrl
|
||||
}
|
||||
return tab, ctrl
|
||||
}
|
||||
|
||||
// activeTabAndCtrl snapshots the active tab and its controller in one locked
|
||||
@@ -1045,6 +1109,20 @@ func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error
|
||||
if tab == nil || tab.sink == nil {
|
||||
return
|
||||
}
|
||||
// Autosave fires once per turn; on a persistently failing disk that would
|
||||
// stream a chat warning after every turn. Rate-limit the user-facing
|
||||
// notice per tab (the slog line above always records every failure). Saves
|
||||
// triggered by an explicit action are one-shot and always surface.
|
||||
if action == "autosave" {
|
||||
tab.saveMu.Lock()
|
||||
now := time.Now()
|
||||
if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
|
||||
tab.saveMu.Unlock()
|
||||
return
|
||||
}
|
||||
tab.lastAutosaveWarnAt = now
|
||||
tab.saveMu.Unlock()
|
||||
}
|
||||
prefix := "Session autosave failed"
|
||||
if strings.TrimSpace(action) != "" && action != "autosave" {
|
||||
prefix = "Session save failed before " + action
|
||||
@@ -1126,7 +1204,7 @@ func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
|
||||
if current := a.tabs[tab.ID]; current == tab {
|
||||
tab.Ctrl = nil
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
@@ -1450,6 +1528,14 @@ func (a *App) NewSession() error {
|
||||
if err := ctrl.NewSession(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The rotated session starts with zero spend: without this reset the tab
|
||||
// telemetry keeps the previous session's totals and the status bar 会话费用
|
||||
// silently turns into an all-sessions running total (#5850).
|
||||
tab.resetTelemetry(ctrl.SessionPath())
|
||||
// Mirror the controller: NewSession cleared the active goal, and the tab's
|
||||
// persisted copy must follow — otherwise the next rebuild/restart would
|
||||
// re-seed the old goal into the fresh session via SetGoal(tab.goal).
|
||||
a.clearTabGoal(tab)
|
||||
a.assignFreshSessionTopic(tab)
|
||||
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
||||
a.invalidatePromptHistoryCache()
|
||||
@@ -1551,14 +1637,32 @@ func (a *App) ClearSession() error {
|
||||
return err
|
||||
}
|
||||
if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
|
||||
return err
|
||||
// Wails bridge return: a raw lease error would carry the session path
|
||||
// and holder id across to the frontend.
|
||||
return userFacingSessionLeaseError("", err)
|
||||
}
|
||||
tab.resetTelemetry()
|
||||
tab.resetTelemetry(ctrl.SessionPath())
|
||||
// Mirror the controller: ClearSession cleared the active goal.
|
||||
a.clearTabGoal(tab)
|
||||
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
||||
a.invalidatePromptHistoryCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
|
||||
// cannot re-seed a goal the controller has already cleared on session rotation.
|
||||
func (a *App) clearTabGoal(tab *WorkspaceTab) {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
tab.goal = ""
|
||||
if current := a.tabs[tab.ID]; current == tab {
|
||||
a.saveTabsLocked()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
|
||||
if tab == nil || oldCtrl == nil {
|
||||
return fmt.Errorf("workspace is still starting")
|
||||
@@ -1582,7 +1686,10 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
snap := a.tabRuntimeSnapshot(tab)
|
||||
oldSink := snap.sink
|
||||
if oldSink != nil {
|
||||
oldSink.setBinding(detachedRuntimeTabID(oldPath), nil)
|
||||
// Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
|
||||
// derives — a raw path here would hash to a different detached id on
|
||||
// Windows where keys are case-folded.
|
||||
oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
|
||||
oldSink.clearContext()
|
||||
}
|
||||
if oldCtrl.RuntimeStatus().Cancellable {
|
||||
@@ -1643,25 +1750,46 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, snap.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
|
||||
newCtrl.SetGoal(snap.goal)
|
||||
// Clearing the session clears the active goal too (same contract as
|
||||
// Controller.ClearSession): the snapshot's goal belongs to the destroyed
|
||||
// conversation and must not seed the replacement.
|
||||
path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
newCtrl.Close()
|
||||
return err
|
||||
// Surfaces through ClearSession's Wails return; keep the holder's
|
||||
// path/pid/writer id out of it.
|
||||
return userFacingSessionLeaseError("", err)
|
||||
}
|
||||
newCtrl.SetSessionPath(path)
|
||||
|
||||
a.mu.Lock()
|
||||
if current := a.tabs[tab.ID]; current == tab {
|
||||
tab.Ctrl = newCtrl
|
||||
tab.sink = newSink
|
||||
tab.SessionPath = path
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.Ready = true
|
||||
tab.StartupErr = ""
|
||||
a.saveTabsLocked()
|
||||
if current := a.tabs[tab.ID]; current != tab {
|
||||
a.mu.Unlock()
|
||||
// The old session is already destroyed either way; release what this
|
||||
// clear acquired for the replaced tab (fresh controller and its
|
||||
// lease) so neither leaks, and still finish the old runtime teardown.
|
||||
newCtrl.Close()
|
||||
tab.releaseSessionLease()
|
||||
oldCtrl.CloseAfterDestroy()
|
||||
a.emitProjectTreeChanged()
|
||||
return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
|
||||
}
|
||||
tab.Ctrl = newCtrl
|
||||
tab.sink = newSink
|
||||
tab.SessionPath = path
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.Ready = true
|
||||
clearTabStartupError(tab)
|
||||
tab.goal = ""
|
||||
// Supersede any in-flight startup build: the session it was resuming
|
||||
// was just destroyed, and finishing later would pass the generation
|
||||
// check and overwrite this controller.
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
// Same contract as ClearSession's non-running path: the replacement
|
||||
// session starts with zero spend.
|
||||
tab.resetTelemetry(path)
|
||||
oldCtrl.CloseAfterDestroy()
|
||||
a.emitProjectTreeChanged()
|
||||
return nil
|
||||
@@ -2247,6 +2375,10 @@ func channelDisplayName(provider, domain string) string {
|
||||
// has an in-process runtime, the runtime is cancelled and removed first so
|
||||
// autosave cannot recreate or append to the deleted file later.
|
||||
func (a *App) DeleteSession(path string) error {
|
||||
return friendlySessionFileError(a.deleteSession(path))
|
||||
}
|
||||
|
||||
func (a *App) deleteSession(path string) error {
|
||||
dir := a.activeSessionDir()
|
||||
sessionPath, key, err := validateSessionPath(dir, path)
|
||||
if err != nil {
|
||||
@@ -2728,6 +2860,10 @@ func (a *App) activeSessionPath(dir string) string {
|
||||
|
||||
// RestoreSession moves a trashed session back into the saved-session list.
|
||||
func (a *App) RestoreSession(path string) error {
|
||||
return friendlySessionFileError(a.restoreSession(path))
|
||||
}
|
||||
|
||||
func (a *App) restoreSession(path string) error {
|
||||
dir, err := a.trashedSessionDir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2786,6 +2922,10 @@ func (a *App) sessionOpen(dir, sessionPath string) bool {
|
||||
// PurgeTrashedSession permanently removes a trashed session and its title/display
|
||||
// sidecars.
|
||||
func (a *App) PurgeTrashedSession(path string) error {
|
||||
return friendlySessionFileError(a.purgeTrashedSession(path))
|
||||
}
|
||||
|
||||
func (a *App) purgeTrashedSession(path string) error {
|
||||
dir, err := a.trashedSessionDir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -3015,7 +3155,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
tab.SessionPath = sessionPath
|
||||
applyTabSessionProfile(tab, profile)
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
a.saveTabsLocked()
|
||||
@@ -3057,7 +3197,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
tab.SessionPath = sessionPath
|
||||
applyTabSessionProfile(tab, profile)
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
a.saveTabsLocked()
|
||||
@@ -4884,6 +5024,15 @@ func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
||||
|
||||
var info ContextInfo
|
||||
if tab != nil {
|
||||
// Re-key first: a controller-side rotation (typed /new) may have
|
||||
// swapped sessions without the App noticing, and the stale totals
|
||||
// would otherwise be reported — and then persisted — under the new
|
||||
// session (#5850).
|
||||
if ctrl != nil {
|
||||
if sp := ctrl.SessionPath(); sp != "" {
|
||||
tab.syncTelemetryToSession(sp)
|
||||
}
|
||||
}
|
||||
snap := tab.telemetrySnapshot()
|
||||
info.SessionTokens = snap.Usage.TotalTokens
|
||||
info.SessionCost = snap.Usage.SessionCost
|
||||
@@ -6949,9 +7098,13 @@ type sessionLeaseBusyError struct {
|
||||
}
|
||||
|
||||
func (e *sessionLeaseBusyError) Error() string {
|
||||
// The raw SessionLeaseError text carries the session path and the
|
||||
// holder's host-pid-writer id; every user-facing surface must render
|
||||
// this wrapper instead. An empty setting means the failure gated opening
|
||||
// the session itself (startup bind), not changing a setting on it.
|
||||
setting := strings.TrimSpace(e.setting)
|
||||
if setting == "" {
|
||||
setting = "this setting"
|
||||
return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
|
||||
}
|
||||
return fmt.Sprintf("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing %s", setting)
|
||||
}
|
||||
@@ -6973,6 +7126,25 @@ func userFacingSessionLeaseError(setting string, err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// sessionPathAfterSnapshot returns where a controller rebuild should keep
|
||||
// persisting after the old controller was snapshotted. Snapshotting is not
|
||||
// path-neutral: a snapshot conflict can recover by retargeting the controller
|
||||
// (and the tab's session lease, via handleTabSessionRecovered) to a recovery
|
||||
// branch, so a prevPath captured before the snapshot may be stale. Reusing the
|
||||
// stale path would bind the rebuilt controller — carrying the just-recovered
|
||||
// transcript — back to the original file, turning every later save into a new
|
||||
// conflict that derives yet another recovery branch. Falls back to fallback
|
||||
// when the controller is gone or persistence is disabled (empty SessionPath).
|
||||
func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
|
||||
if ctrl == nil {
|
||||
return fallback
|
||||
}
|
||||
if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
|
||||
return path
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
|
||||
@@ -6994,10 +7166,16 @@ func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path strin
|
||||
return false
|
||||
}
|
||||
var leaseErr *agent.SessionLeaseError
|
||||
if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil {
|
||||
if !errors.As(err, &leaseErr) || leaseErr == nil {
|
||||
return false
|
||||
}
|
||||
if leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID() {
|
||||
// A readable info naming a foreign runtime is respected here; reclaim
|
||||
// would refuse it anyway. A nil Info (lease.json deleted by the user,
|
||||
// quarantined by AV, or torn by a crash) must still attempt the reclaim:
|
||||
// the OS lock is the arbiter there, and refusing on missing metadata
|
||||
// wedges a session nobody actually holds as permanently busy.
|
||||
if leaseErr.Info != nil &&
|
||||
(leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
@@ -7106,6 +7284,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
|
||||
return err
|
||||
}
|
||||
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
||||
carried = oldCtrl.History()
|
||||
}
|
||||
|
||||
@@ -7155,6 +7334,9 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
tab.model = name
|
||||
tab.effort = cloneStringPtr(effortOverride)
|
||||
tab.Label = newCtrl.Label()
|
||||
// Supersede any in-flight startup build: it would otherwise finish later,
|
||||
// overwrite this controller, and release/steal the tab's session lease.
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
if oldCtrl != nil {
|
||||
@@ -7261,6 +7443,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
|
||||
return err
|
||||
}
|
||||
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
||||
carried = oldCtrl.History()
|
||||
}
|
||||
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
||||
@@ -7302,8 +7485,9 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
tab.model = modelRef
|
||||
tab.effort = &effort
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = true
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
if oldCtrl != nil {
|
||||
@@ -7386,6 +7570,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
|
||||
return err
|
||||
}
|
||||
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
||||
carried = oldCtrl.History()
|
||||
}
|
||||
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
||||
@@ -7427,8 +7612,9 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
tab.model = modelRef
|
||||
tab.tokenMode = mode
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = true
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
if oldCtrl != nil {
|
||||
|
||||
@@ -93,13 +93,12 @@ func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
|
||||
// nil sink ctx (no webview) must not disable persistence.
|
||||
func TestTurnDonePersistsSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
_ = path
|
||||
a, tab := appWithTab(t, path)
|
||||
_, tab := appWithTab(t, path)
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
waitForFile(t, path, "remember this turn")
|
||||
_ = a
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
|
||||
|
||||
@@ -642,6 +642,41 @@ func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
|
||||
// into ensureTopicIndexed, whose intentional prepend clears the delete
|
||||
// tombstone and resurrects the topic the user removed.
|
||||
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
// Race product on disk: deleted topic whose default title lingered in the
|
||||
// global title map (title-only, absent from GlobalTopics, no sessions).
|
||||
tombstonedID := "topic_tombstone_blank"
|
||||
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
|
||||
t.Fatalf("set lingering title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
meta, err := NewApp().EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == tombstonedID {
|
||||
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
|
||||
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
|
||||
}
|
||||
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession skips the snapshot when the current tab has no real conversation content.
|
||||
|
||||
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
||||
|
||||
+630
-10
@@ -31,6 +31,7 @@ import (
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/store"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -111,6 +112,29 @@ func isolateDesktopUserDirs(t *testing.T) string {
|
||||
return home
|
||||
}
|
||||
|
||||
func primarySessionFiles(paths []string) []string {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if store.IsSessionTranscriptName(filepath.Base(path)) {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readConflictLogLines(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read conflict log: %v", err)
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(text, "\n")
|
||||
}
|
||||
|
||||
func setDesktopTestCredential(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
if _, err := config.SetCredential(key, value); err != nil {
|
||||
@@ -204,6 +228,7 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
resetWorkspaceGitBranchMetaCacheForTest(t)
|
||||
|
||||
repo := t.TempDir()
|
||||
configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
|
||||
@@ -252,8 +277,15 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
|
||||
if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
|
||||
t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
|
||||
}
|
||||
if got.GitBranch != "feature/meta" {
|
||||
t.Fatalf("gitBranch = %q, want feature/meta", got.GitBranch)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,6 +1387,53 @@ func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
cfg := config.Default()
|
||||
stepfun, ok := config.CuratedProviderPreset("stepfun")
|
||||
if !ok || len(stepfun.Entries) != 1 {
|
||||
t.Fatal("missing stepfun preset")
|
||||
}
|
||||
stepfunEntry := stepfun.Entries[0]
|
||||
stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
|
||||
stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
|
||||
if !ok || len(stepfunAnthropic.Entries) != 1 {
|
||||
t.Fatal("missing stepfun-anthropic preset")
|
||||
}
|
||||
stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
|
||||
stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
|
||||
cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
|
||||
cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
for _, id := range []string{"stepfun", "stepfun-anthropic"} {
|
||||
presetView := providerPresetViewByID(t, view, id)
|
||||
if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
|
||||
t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView)
|
||||
}
|
||||
}
|
||||
|
||||
migrated := config.LoadForEdit(config.UserConfigPath())
|
||||
stepfunEntryView, ok := migrated.Provider("stepfun")
|
||||
if !ok {
|
||||
t.Fatal("stepfun provider missing after migration")
|
||||
}
|
||||
if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" {
|
||||
t.Fatalf("stepfun base_url = %q, want official URL", got)
|
||||
}
|
||||
stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("stepfun-anthropic provider missing after migration")
|
||||
}
|
||||
if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" {
|
||||
t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
preset, ok := config.CuratedProviderPreset("mimo-api")
|
||||
@@ -1781,6 +1860,65 @@ func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderKeyRebuildSupersedesInFlightStartupBuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{{
|
||||
Name: "old",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://example.invalid/v1",
|
||||
Model: "old-model",
|
||||
APIKeyEnv: "OLD_MODEL_KEY",
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
// Model the async startup build still being in flight: no controller yet,
|
||||
// a live build generation, and a cancellable build context.
|
||||
buildCtx, buildCancel := context.WithCancel(context.Background())
|
||||
const startupGeneration = 1
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_key_rebuild",
|
||||
Scope: "global",
|
||||
SessionPath: sessionPath,
|
||||
model: "old/old-model",
|
||||
buildGeneration: startupGeneration,
|
||||
buildCancel: buildCancel,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
|
||||
if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
|
||||
t.Fatalf("SetProviderKey: %v", err)
|
||||
}
|
||||
if tab.Ctrl == nil {
|
||||
t.Fatal("provider-key rebuild did not install a controller")
|
||||
}
|
||||
defer tab.Ctrl.Close()
|
||||
|
||||
assertTabBuildSuperseded(t, app, tab, startupGeneration, buildCtx)
|
||||
}
|
||||
|
||||
func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
@@ -1999,6 +2137,7 @@ func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
|
||||
sink: &tabEventSink{tabID: "tab_deferred_retry", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
@@ -2109,6 +2248,7 @@ func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) {
|
||||
sink: &tabEventSink{tabID: "tab_pending", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
other := &WorkspaceTab{
|
||||
ID: "tab_other",
|
||||
Scope: "global",
|
||||
@@ -2264,6 +2404,137 @@ func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{{
|
||||
Name: "old",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://example.invalid/v1",
|
||||
Model: "old-model",
|
||||
APIKeyEnv: "OLD_MODEL_KEY",
|
||||
SupportedEfforts: []string{"low", "max"},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl")
|
||||
disk := agent.NewSession("old system prompt")
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := disk.Save(recoveryPath); err != nil {
|
||||
t.Fatalf("save recovery branch: %v", err)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
meta.Recovered = true
|
||||
meta.ParentID = "effort-switch-conflict"
|
||||
meta.RecoveryReason = "snapshot conflict"
|
||||
meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
|
||||
if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil {
|
||||
t.Fatalf("SaveBranchMeta: %v", err)
|
||||
}
|
||||
|
||||
stale := agent.NewSession("old system prompt")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_depth_cap_effort",
|
||||
Scope: "global",
|
||||
SessionPath: recoveryPath,
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
oldCtrl := control.New(control.Options{
|
||||
Executor: oldExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: recoveryPath,
|
||||
Label: "old",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
tab.Ctrl = oldCtrl
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
stale.IncrementRewrite()
|
||||
|
||||
if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != recoveryPath {
|
||||
t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
|
||||
}
|
||||
if got := tab.currentSessionPath(); got != recoveryPath {
|
||||
t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
|
||||
}
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
|
||||
t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
|
||||
}
|
||||
|
||||
lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
|
||||
t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
|
||||
t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
|
||||
}
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot after effort switch recovery: %v", err)
|
||||
}
|
||||
afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
|
||||
if len(afterLines) != len(lines) {
|
||||
t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines)
|
||||
}
|
||||
matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches after snapshot: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
||||
@@ -2649,6 +2920,108 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old", "new"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
|
||||
{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
|
||||
current := agent.NewSession("old system prompt")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("save current session: %v", err)
|
||||
}
|
||||
|
||||
stale := agent.NewSession("old system prompt")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_recovery_model",
|
||||
Scope: "global",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
oldCtrl := control.New(control.Options{
|
||||
Executor: oldExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "old",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
tab.Ctrl = oldCtrl
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
|
||||
t.Fatalf("SetModelForTab: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
|
||||
t.Fatalf("model switch session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
|
||||
}
|
||||
if got := tab.currentSessionPath(); got != recoveryPath {
|
||||
t.Fatalf("tab current session path = %q, want recovery path %q", got, recoveryPath)
|
||||
}
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
|
||||
t.Fatalf("tab lease path = %q, want recovery path %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after model switch = %v, want only %q", matches, recoveryPath)
|
||||
}
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot after model switch recovery: %v", err)
|
||||
}
|
||||
matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches after snapshot: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
@@ -3557,6 +3930,234 @@ func TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertTabBuildSuperseded checks that the startup build registered before the
|
||||
// mutation (generation) can no longer install its controller and that its
|
||||
// build context was cancelled.
|
||||
func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
|
||||
t.Helper()
|
||||
app.mu.Lock()
|
||||
superseded := app.tabBuildSupersededLocked(tab, generation)
|
||||
app.mu.Unlock()
|
||||
if !superseded {
|
||||
t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
|
||||
}
|
||||
select {
|
||||
case <-buildCtx.Done():
|
||||
default:
|
||||
t.Fatal("in-flight startup build context was not cancelled")
|
||||
}
|
||||
if tab.buildCancel != nil {
|
||||
t.Fatal("build cancel was not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProviderSupersedesInFlightStartupBuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "prov-b/model-b1"
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
|
||||
{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
|
||||
}
|
||||
cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
// Model the async startup build still being in flight for the affected
|
||||
// tab: no controller yet, a live generation, a cancellable build context.
|
||||
buildCtx, buildCancel := context.WithCancel(context.Background())
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_a",
|
||||
Scope: "global",
|
||||
model: "prov-a/model-a1",
|
||||
buildGeneration: 1,
|
||||
buildCancel: buildCancel,
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.DeleteProvider("prov-a"); err != nil {
|
||||
t.Fatalf("DeleteProvider: %v", err)
|
||||
}
|
||||
assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
|
||||
if tab.model != "prov-b/model-b1" {
|
||||
t.Fatalf("tab model after delete = %q, want prov-b/model-b1", tab.model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveBuiltInProviderAccessSupersedesInFlightStartupBuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "prov-b/model-b1"
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
|
||||
{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
|
||||
}
|
||||
cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
buildCtx, buildCancel := context.WithCancel(context.Background())
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_ds",
|
||||
Scope: "global",
|
||||
model: "deepseek/deepseek-chat",
|
||||
buildGeneration: 1,
|
||||
buildCancel: buildCancel,
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.RemoveProviderAccess("deepseek"); err != nil {
|
||||
t.Fatalf("RemoveProviderAccess: %v", err)
|
||||
}
|
||||
assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
|
||||
if tab.model != "prov-b/model-b1" {
|
||||
t.Fatalf("tab model after access removal = %q, want prov-b/model-b1", tab.model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{{
|
||||
Name: "old",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://example.invalid/v1",
|
||||
Model: "old-model",
|
||||
APIKeyEnv: "OLD_MODEL_KEY",
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
|
||||
oldSession := agent.NewSession("old system prompt")
|
||||
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
|
||||
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
// A runtime is attached while an older async build is still in flight
|
||||
// (e.g. attached via topic activation); destroying the session must
|
||||
// invalidate that build so it cannot resurrect the destroyed session.
|
||||
buildCtx, buildCancel := context.WithCancel(context.Background())
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_clear",
|
||||
Scope: "global",
|
||||
SessionPath: sessionPath,
|
||||
model: "old/old-model",
|
||||
Ready: true,
|
||||
Ctrl: oldCtrl,
|
||||
buildGeneration: 1,
|
||||
buildCancel: buildCancel,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
|
||||
if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
|
||||
t.Fatalf("clearActiveSessionRuntime: %v", err)
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
|
||||
t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
|
||||
}
|
||||
defer tab.Ctrl.Close()
|
||||
assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
|
||||
}
|
||||
|
||||
func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{{
|
||||
Name: "old",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://example.invalid/v1",
|
||||
Model: "old-model",
|
||||
APIKeyEnv: "OLD_MODEL_KEY",
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
|
||||
oldSession := agent.NewSession("old system prompt")
|
||||
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
|
||||
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_replaced",
|
||||
Scope: "global",
|
||||
SessionPath: sessionPath,
|
||||
model: "old/old-model",
|
||||
Ready: true,
|
||||
Ctrl: oldCtrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
// The tab entry now points at a replacement struct (the tab was closed and
|
||||
// reopened while the clear ran off-lock), so the swap must not apply.
|
||||
replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
|
||||
err := app.clearActiveSessionRuntime(tab, oldCtrl)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
|
||||
t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
|
||||
}
|
||||
if replacement.Ctrl != nil {
|
||||
t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
|
||||
}
|
||||
if tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
|
||||
}
|
||||
if key := tab.sessionLeaseRuntimeKey(); key != "" {
|
||||
t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
|
||||
@@ -5002,7 +5603,7 @@ func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionValidTrashRejectsNonEmptyLive(t *testing.T) {
|
||||
func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
@@ -5026,15 +5627,34 @@ func TestDeleteSessionValidTrashRejectsNonEmptyLive(t *testing.T) {
|
||||
app := NewApp()
|
||||
app.setTestCtrl(activeCtrl, "")
|
||||
|
||||
err := app.DeleteSession(filepath.Base(path))
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists in trash") {
|
||||
t.Fatalf("DeleteSession conflict error = %v, want valid trash conflict", err)
|
||||
if err := app.DeleteSession(filepath.Base(path)); err != nil {
|
||||
t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("non-empty live session should remain: %v", err)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("live session should be moved out of active history, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("trash session should remain: %v", err)
|
||||
if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
|
||||
t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
|
||||
}
|
||||
trashed, err := listTrashedSessionFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("list trash: %v", err)
|
||||
}
|
||||
var renamedPath string
|
||||
for _, candidate := range trashed {
|
||||
if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
|
||||
renamedPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if renamedPath == "" {
|
||||
t.Fatalf("renamed trash copy not found in %#v", trashed)
|
||||
}
|
||||
if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
|
||||
t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
|
||||
}
|
||||
if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
|
||||
t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
|
||||
failure := errors.New("disk unhappy")
|
||||
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
first := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if first.IsZero() {
|
||||
t.Fatal("first autosave warning did not record its timestamp")
|
||||
}
|
||||
|
||||
// Within the window: suppressed, timestamp untouched.
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
second := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !second.Equal(first) {
|
||||
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
|
||||
}
|
||||
|
||||
// Window expired: warns again.
|
||||
tab.saveMu.Lock()
|
||||
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
|
||||
tab.saveMu.Unlock()
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
third := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !third.After(first) {
|
||||
t.Fatal("expired window did not re-arm the autosave warning")
|
||||
}
|
||||
|
||||
// Explicit user actions are never debounced (state untouched).
|
||||
app.reportTabSnapshotError(tab, "changing model", failure)
|
||||
tab.saveMu.Lock()
|
||||
fourth := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !fourth.Equal(third) {
|
||||
t.Fatal("action-save warning must not consume the autosave debounce window")
|
||||
}
|
||||
}
|
||||
+153
-6
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -10,18 +11,22 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// deferredRebuildRetryInterval is how often the retry loop probes a held
|
||||
// session lease. Package-level so tests can shorten it.
|
||||
var deferredRebuildRetryInterval = 2 * time.Second
|
||||
|
||||
const deferredStartupBuildLabel = "__startup__"
|
||||
|
||||
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
|
||||
// runtime could not refresh because the session lease was held by another
|
||||
// Reasonix process. A single background loop probes the lease and replays the
|
||||
// rebuild once the other side releases it. The loop only runs after
|
||||
// enableDeferredRebuildRetry (the wails startup hook); tests that never call
|
||||
// it get the pending bookkeeping without a background goroutine.
|
||||
// runtime could not refresh, plus tabs whose initial startup failed, because
|
||||
// the session lease was held by another Reasonix process. A single background
|
||||
// loop probes the lease and replays the rebuild once the other side releases
|
||||
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
|
||||
// hook); tests that never call it get the pending bookkeeping without a
|
||||
// background goroutine.
|
||||
type deferredRebuildState struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]string // tab ID -> setting label for notices
|
||||
@@ -75,6 +80,14 @@ func (a *App) scheduleDeferredRebuild(tabID, setting string) {
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
func (a *App) scheduleDeferredStartupBuild(tabID string) {
|
||||
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
|
||||
}
|
||||
|
||||
func isDeferredStartupBuild(setting string) bool {
|
||||
return setting == deferredStartupBuildLabel
|
||||
}
|
||||
|
||||
func (a *App) clearDeferredRebuild(tabID string) {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
@@ -127,10 +140,16 @@ func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
|
||||
// deferredRebuildTickDone runs one retry pass and reports true when the loop
|
||||
// should exit because nothing is pending anymore.
|
||||
func (a *App) deferredRebuildTickDone() bool {
|
||||
return a.deferredRebuildTick(true)
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildTick(markIdle bool) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
if d.stopped || len(d.pending) == 0 {
|
||||
d.running = false
|
||||
if markIdle {
|
||||
d.running = false
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
@@ -146,6 +165,15 @@ func (a *App) deferredRebuildTickDone() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) kickDeferredRebuildRetry() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
a.goSafe("deferredRebuildKick", func() {
|
||||
_ = a.deferredRebuildTick(false)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -156,6 +184,10 @@ func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if isDeferredStartupBuild(setting) {
|
||||
a.retryDeferredStartupBuild(tabID, tab)
|
||||
return
|
||||
}
|
||||
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
|
||||
// the session lease, and a concurrent manual rebuild's ensureSessionLease
|
||||
// would see that probe as "held by another runtime" and spuriously defer.
|
||||
@@ -200,6 +232,121 @@ func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return
|
||||
}
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
|
||||
}
|
||||
|
||||
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
|
||||
if tab == nil {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
|
||||
}
|
||||
|
||||
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
|
||||
buildCtx, cancel := context.WithCancel(a.bootContext())
|
||||
a.mu.Lock()
|
||||
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if tab.Ctrl != nil {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
if tab.buildCancel != nil {
|
||||
tab.buildCancel()
|
||||
}
|
||||
tab.buildCancel = cancel
|
||||
tab.Ready = false
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
}
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
|
||||
a.mu.RLock()
|
||||
stillCurrent := false
|
||||
var ctrl control.SessionAPI
|
||||
startupErr := ""
|
||||
leaseHeld := false
|
||||
if tab != nil {
|
||||
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
|
||||
ctrl = tab.Ctrl
|
||||
startupErr = tab.StartupErr
|
||||
leaseHeld = tab.StartupErrLeaseHeld
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if !stillCurrent || ctrl != nil {
|
||||
return nil
|
||||
}
|
||||
if leaseHeld {
|
||||
return agent.ErrSessionLeaseHeld
|
||||
}
|
||||
if strings.TrimSpace(startupErr) != "" {
|
||||
return fmt.Errorf("session startup: %s", startupErr)
|
||||
}
|
||||
return fmt.Errorf("session startup: controller was not built")
|
||||
}
|
||||
|
||||
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
|
||||
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return false
|
||||
}
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return false
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
} else {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
|
||||
// could be acquired right now, without touching tab.sessionLease (only the
|
||||
// serialized rebuild paths may mutate that). The probe path can lag the
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
|
||||
@@ -2279,8 +2279,8 @@ export default function App() {
|
||||
|
||||
// send wrapper: commits any pending optimistic rewind before sending.
|
||||
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
|
||||
if (activeTab?.readOnly) throw new Error("channel session is read-only");
|
||||
if (!controllerReady) throw new Error("workspace is still starting");
|
||||
if (activeTab?.readOnly) throw new Error(t("composer.readOnlyChannel"));
|
||||
if (!controllerReady) throw new Error(t("composer.workspaceStarting"));
|
||||
const rs = rewindStateRef.current;
|
||||
if (rs) {
|
||||
rewindStateRef.current = null;
|
||||
@@ -2296,7 +2296,7 @@ export default function App() {
|
||||
// Rewind failed: the Go conversation is intact. Do not send; the
|
||||
// controller emits a notice with the reason.
|
||||
setRewindState(null);
|
||||
throw new Error("rewind failed");
|
||||
throw new Error(t("rewind.failed"));
|
||||
}
|
||||
setRewindSignal((v) => v + 1);
|
||||
if (rs.scope === "both") {
|
||||
@@ -2306,7 +2306,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
await send(displayText, submitText);
|
||||
}, [activeTab?.readOnly, controllerReady, send, rewind]);
|
||||
}, [activeTab?.readOnly, controllerReady, send, rewind, t]);
|
||||
|
||||
const handleTranscriptPrompt = useCallback((text: string) => {
|
||||
if (!controllerReady) return;
|
||||
@@ -2548,7 +2548,7 @@ export default function App() {
|
||||
} else {
|
||||
throw new Error(scope === "global" && !session.topicId
|
||||
? t("history.failedOpenSession")
|
||||
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
|
||||
: (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession")));
|
||||
}
|
||||
if (!latest()) return;
|
||||
seedActiveTabMeta(targetTab);
|
||||
@@ -2884,13 +2884,6 @@ export default function App() {
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
|
||||
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
|
||||
const recoveryBannerTitle = activeTab?.recovered
|
||||
? [
|
||||
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
|
||||
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
|
||||
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const sidebarWorkbench = desktopLayoutStyle === "workbench";
|
||||
const windowsFramelessChrome = desktopPlatform === "windows";
|
||||
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -3443,7 +3436,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{activeTab?.recovered && !sidebarImDetailConnection && (
|
||||
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
|
||||
<div className="banner banner--recovery">
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000
|
||||
ok(false, label);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
function installDom(language = "en-US") {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
@@ -62,6 +62,7 @@ function installDom() {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
@@ -129,7 +130,7 @@ async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]
|
||||
console.log("\napproval modal file references");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const dom = installDom("en-US");
|
||||
mockApp({
|
||||
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
|
||||
SearchFileRefs: async () => [],
|
||||
@@ -178,6 +179,86 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-approval-zh",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("仅本次不进沙箱运行:go test ./..."), "sandbox escape approval localizes subject in Chinese UI");
|
||||
ok(text.includes("Windows 沙箱启动这条命令时失败"), "sandbox escape approval localizes English backend reason in Chinese UI");
|
||||
ok(text.includes("允许一次"), "sandbox escape Chinese approval shows allow once");
|
||||
ok(text.includes("本会话使用真实环境"), "sandbox escape Chinese approval shows session grant");
|
||||
ok(text.includes("拒绝"), "sandbox escape Chinese approval shows deny");
|
||||
ok(!text.includes("总是允许"), "sandbox escape Chinese approval hides persistent grant");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval-zh",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("保存记忆"), "remember approval localizes tool label in Chinese UI");
|
||||
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"), "remember approval localizes subject prefix in Chinese UI");
|
||||
ok(text.includes("正文: Use Vitest for frontend tests."), "remember approval localizes body label in Chinese UI");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "plan-mode-read-only-command-zh",
|
||||
tool: "plan_mode_read_only_command",
|
||||
subject: "Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
|
||||
reason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("计划模式只读命令"), "plan-mode read-only command approval localizes tool label in Chinese UI");
|
||||
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"), "plan-mode read-only command approval localizes subject in Chinese UI");
|
||||
ok(text.includes("不在 Reasonix 内置只读集合中"), "plan-mode read-only command approval localizes reason in Chinese UI");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
@@ -260,8 +341,8 @@ console.log("\napproval modal file references");
|
||||
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|4Deny",
|
||||
"fresh-human approval keeps allow/deny shortcut keys",
|
||||
"1Allow once|2Deny",
|
||||
"fresh-human approval keeps conventional allow/deny shortcut keys",
|
||||
);
|
||||
|
||||
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
|
||||
@@ -280,5 +361,108 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval-deny",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[false, false, false]]), "fresh-human numeric 2 denies");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-approval",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("bash sandbox escape"), "sandbox escape approval uses a clear tool label");
|
||||
ok(text.includes("Allow once"), "sandbox escape approval shows allow once");
|
||||
ok(text.includes("Use real environment for this session"), "sandbox escape approval shows session grant");
|
||||
ok(text.includes("Deny"), "sandbox escape approval shows deny");
|
||||
ok(!text.includes("Always allow"), "sandbox escape approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|2Use real environment for this session|3Deny",
|
||||
"sandbox escape approval keeps conventional allow once/session/deny shortcut keys",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(JSON.stringify(answers), JSON.stringify([{ allow: true, session: true, persist: false }]), "sandbox escape Enter on selected session action grants session");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-deny-approval",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(JSON.stringify(answers), JSON.stringify([{ allow: false, session: false, persist: false }]), "sandbox escape numeric 3 denies");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
|
||||
let passed = 0;
|
||||
@@ -109,6 +109,59 @@ const infoCost = contextCostDisplay({
|
||||
usage: { cost: 0, costUsd: 0, currency: "¥" },
|
||||
});
|
||||
eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
|
||||
const singleRequestOnly = contextCostDisplay({
|
||||
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
|
||||
sessionCost: 0,
|
||||
sessionCurrency: "¥",
|
||||
usage: { cost: 0.42, costUsd: 0.42, currency: "¥" },
|
||||
});
|
||||
eq(
|
||||
singleRequestOnly,
|
||||
{ amount: 0, currency: "¥" },
|
||||
"a single request's cost never renders under the session-cost label",
|
||||
);
|
||||
const localAccumulated = contextCostDisplay({
|
||||
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
|
||||
sessionCost: 1.5,
|
||||
sessionCurrency: "¥",
|
||||
usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
|
||||
});
|
||||
eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
|
||||
|
||||
console.log("\ncontext panel session cache scope");
|
||||
|
||||
eq(
|
||||
contextSessionCache(
|
||||
{ sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
|
||||
{ cacheHitTokens: 800, cacheMissTokens: 200 },
|
||||
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
|
||||
),
|
||||
{ hit: 900, miss: 100 },
|
||||
"all-sources panel telemetry beats executor-only wire counters",
|
||||
);
|
||||
eq(
|
||||
contextSessionCache(
|
||||
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
|
||||
{ cacheHitTokens: 800, cacheMissTokens: 200 },
|
||||
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
|
||||
),
|
||||
{ hit: 800, miss: 200 },
|
||||
"ContextInfo telemetry is the second all-sources choice",
|
||||
);
|
||||
eq(
|
||||
contextSessionCache(
|
||||
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
|
||||
{ cacheHitTokens: 0, cacheMissTokens: 0 },
|
||||
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
|
||||
),
|
||||
{ hit: 700, miss: 300 },
|
||||
"executor-only wire counters only bridge the pre-refresh gap",
|
||||
);
|
||||
eq(
|
||||
contextSessionCache(null, undefined, undefined),
|
||||
{ hit: 0, miss: 0 },
|
||||
"no data renders as empty, not NaN",
|
||||
);
|
||||
eq(formatMoney(infoCost.amount, infoCost.currency, "dash"), "$0.1759", "USD panel cost renders with dollar sign");
|
||||
eq(currencySymbol("楼"), "¥", "unexpected currency text does not leak into money values");
|
||||
eq(currencySymbol("aud"), "AUD ", "unknown ISO currency codes stay readable");
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(ready: boolean): TabMeta {
|
||||
return {
|
||||
id: "tab-ready",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-ready",
|
||||
topicTitle: "Ready race",
|
||||
sessionPath: "/repo/sessions/ready.jsonl",
|
||||
label: "model",
|
||||
ready,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(ready: boolean): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nready event meta sync");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const historyGate = deferred<HistoryMessage[]>();
|
||||
let backendReady = false;
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
let metaCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [tabMeta(backendReady)];
|
||||
},
|
||||
MetaForTab: async () => {
|
||||
metaCalls += 1;
|
||||
return meta(backendReady);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => historyGate.promise,
|
||||
HistoryPageForTab: async () => {
|
||||
historyCalls += 1;
|
||||
const messages = await historyGate.promise;
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
|
||||
eq(historyCalls, 1, "startup begins one history hydration");
|
||||
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
|
||||
|
||||
backendReady = true;
|
||||
await act(async () => {
|
||||
for (const handler of readyHandlers) handler("tab-ready");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
|
||||
|
||||
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
|
||||
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
|
||||
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
|
||||
|
||||
await act(async () => {
|
||||
historyGate.resolve([{ role: "user", content: "hello" }]);
|
||||
await historyGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history finishes", () => controller?.state.hydrating === false);
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,220 @@
|
||||
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises(25);
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
|
||||
return {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: `topic-${id}`,
|
||||
topicTitle: id,
|
||||
sessionPath: `/repo/sessions/${id}.jsonl`,
|
||||
label: "model",
|
||||
ready,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(tabId: string, ready: boolean): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
sessionPath: `/repo/sessions/${tabId}.jsonl`,
|
||||
gitBranch: "main",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nready meta reconcile");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const historyGate = deferred<HistoryMessage[]>();
|
||||
let backendReady = false;
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
let metaCalls = 0;
|
||||
let approvalModeCalls = 0;
|
||||
const metaTabIds: string[] = [];
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [
|
||||
tabMeta("tab-ready", backendReady, true),
|
||||
tabMeta("tab-inactive", true, false),
|
||||
];
|
||||
},
|
||||
MetaForTab: async (tabId: string) => {
|
||||
metaCalls += 1;
|
||||
metaTabIds.push(tabId);
|
||||
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => historyGate.promise,
|
||||
HistoryPageForTab: async (tabId: string) => {
|
||||
historyCalls += 1;
|
||||
const messages = await historyGate.promise;
|
||||
return {
|
||||
messages,
|
||||
startTurn: 0,
|
||||
endTurn: messages.filter((message) => message.role === "user").length,
|
||||
totalTurns: messages.filter((message) => message.role === "user").length,
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetToolApprovalModeForTab: async () => {
|
||||
approvalModeCalls += 1;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
|
||||
eq(historyCalls, 1, "startup begins one active-tab history hydration");
|
||||
eq(listTabsCalls, 1, "startup fetches the active tab once");
|
||||
|
||||
backendReady = true;
|
||||
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
|
||||
|
||||
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
|
||||
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
|
||||
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
|
||||
eq(historyCalls, 1, "ready polling does not start another history hydration");
|
||||
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
|
||||
|
||||
await act(async () => {
|
||||
historyGate.resolve([{ role: "user", content: "hello" }]);
|
||||
await historyGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history finishes", () => controller?.state.hydrating === false);
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(cond: boolean, label: string) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
|
||||
console.log("\nrecovery banner privacy");
|
||||
|
||||
const recoveryBannerMatch = /<div className="banner banner--recovery"[\s\S]*?<\/div>/.exec(appSource);
|
||||
const recoveryBanner = recoveryBannerMatch?.[0] ?? "";
|
||||
|
||||
ok(Boolean(recoveryBannerMatch), "App renders a recovery banner");
|
||||
ok(!recoveryBanner.includes("title="), "recovery banner does not expose internals through a tooltip");
|
||||
ok(!recoveryBanner.includes("recoveryDigest"), "recovery banner does not expose recovery digest");
|
||||
ok(!recoveryBanner.includes("recoveryParentId"), "recovery banner does not expose parent session id");
|
||||
ok(!appSource.includes("recoveryBannerTitle"), "App no longer builds a detailed recovery tooltip");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SettingsPanel,
|
||||
formatProviderExtraBody,
|
||||
parseProviderExtraBody,
|
||||
providerExtraBodyParseError,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
@@ -146,6 +147,26 @@ try {
|
||||
extraBodyRejected = true;
|
||||
}
|
||||
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
|
||||
const extraBodyTestT = ((key: string, vars?: Record<string, string | number>) => {
|
||||
if (key === "settings.providerExtraBodyError") return "localized extra body fallback";
|
||||
if (key === "settings.providerExtraBodyNull") return `${vars?.path} localized null`;
|
||||
return key;
|
||||
}) as any;
|
||||
eq(
|
||||
providerExtraBodyParseError(new SyntaxError("Unexpected token } in JSON"), extraBodyTestT),
|
||||
"localized extra body fallback",
|
||||
"extra body editor localizes JSON syntax errors",
|
||||
);
|
||||
try {
|
||||
parseProviderExtraBody('{ "nested": { "value": null } }', extraBodyTestT);
|
||||
ok(false, "extra body editor rejects localized null validation errors");
|
||||
} catch (e) {
|
||||
eq(
|
||||
providerExtraBodyParseError(e, extraBodyTestT),
|
||||
"extra_body.nested.value localized null",
|
||||
"extra body editor keeps localized structured validation errors",
|
||||
);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
|
||||
@@ -577,6 +577,8 @@ await act(async () => {
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
|
||||
ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
|
||||
historyH = deferred<HistoryMessage[]>();
|
||||
holdNextHistoryForH = true;
|
||||
await act(async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
|
||||
import { buildStepGroups, buildTurnGroups, createWarmLayerState, lastQuestionTurn, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
@@ -105,6 +105,22 @@ console.log("\ntranscript grouping contract");
|
||||
eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
|
||||
eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
|
||||
ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
|
||||
eq(lastQuestionTurn([
|
||||
{ id: "u0", text: "first", turn: 0 },
|
||||
{ id: "u1", text: "second", turn: 1 },
|
||||
], visibleTurns), 1, "last question turn follows visible ordinal fallback");
|
||||
eq(lastQuestionTurn([
|
||||
{ id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
|
||||
{ id: "u1", text: "live without server stamp yet", turn: 1 },
|
||||
{ id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
|
||||
], backendTurns), 3, "last question turn follows non-contiguous backend turn");
|
||||
|
||||
const pagedTurns = questionTurnsById([
|
||||
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
|
||||
]);
|
||||
eq(lastQuestionTurn([
|
||||
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
|
||||
], pagedTurns), 1060, "last question turn supports paged history windows");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import type { Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
|
||||
@@ -82,6 +82,39 @@ function usage(source: string): WireUsage {
|
||||
|
||||
console.log("\nuse controller meta");
|
||||
|
||||
{
|
||||
eq(
|
||||
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
|
||||
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"model busy guard is localized",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
|
||||
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
|
||||
"model lease conflict explains the safe path",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText("workspace is still starting"),
|
||||
"This session is still starting. Try changing models again in a moment.",
|
||||
"model startup race asks the user to retry later",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
|
||||
"The current session changed while switching models. Try once more.",
|
||||
"model tab race asks the user to retry",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText('unknown model "missing"'),
|
||||
'Unknown model "missing".',
|
||||
"model unknown error is localized",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
|
||||
'Model "other/other-model" is unavailable because provider "other" is not added.',
|
||||
"model provider access error is localized",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
|
||||
@@ -110,6 +143,100 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"token mode busy guard is localized",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
|
||||
"The current session changed while switching token saver. Try once more.",
|
||||
"token mode tab race asks the user to retry",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
localizedBackendNoticeText("Session autosave failed: disk full"),
|
||||
"Session autosave failed: disk full",
|
||||
"backend autosave notice is localized through the active dictionary",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("Session save failed before changing model: disk full"),
|
||||
"Session save failed before changing models: disk full",
|
||||
"backend save-before-action notice localizes the action",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
|
||||
'Model "old/model" is no longer available; switched to new/model.',
|
||||
"backend model fallback notice is localized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"legacy recovery branch notice hides internal branch id",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery copy notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"legacy repeated recovery conflict notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"repeated recovery conflict notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"adopted transcript notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
"covered adopted transcript notice is user-facing",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
});
|
||||
s = reducer(s, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
|
||||
});
|
||||
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
|
||||
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed in the live transcript");
|
||||
eq(s.seq, 1, "collapsed recovery notice does not consume a sequence id");
|
||||
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
|
||||
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
|
||||
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
|
||||
}
|
||||
|
||||
{
|
||||
const history: HistoryMessage[] = [
|
||||
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
|
||||
{ role: "user", content: "continue" },
|
||||
];
|
||||
const hydrated = historyMessagesToItems(history, "h");
|
||||
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
|
||||
const users = hydrated.items.filter((item) => item.kind === "user");
|
||||
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed when hydrating history");
|
||||
eq(users[0]?.kind === "user" && users[0].id, "h1", "collapsed history notice keeps later item ids compact");
|
||||
eq(hydrated.seq, 2, "collapsed history notice does not inflate the hydrated sequence");
|
||||
}
|
||||
|
||||
{
|
||||
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
@@ -28,7 +28,89 @@ function animateShelfExit(
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
|
||||
}
|
||||
|
||||
function approvalToolLabel(tool: string, t: Translator): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return t("approval.toolLabelBash");
|
||||
case "edit_file":
|
||||
return t("approval.toolLabelEditFile");
|
||||
case "write_file":
|
||||
return t("approval.toolLabelWriteFile");
|
||||
case "multi_edit":
|
||||
return t("approval.toolLabelMultiEdit");
|
||||
case "move_file":
|
||||
return t("approval.toolLabelMoveFile");
|
||||
case "web_fetch":
|
||||
return t("approval.toolLabelWebFetch");
|
||||
case "run_skill":
|
||||
return t("approval.toolLabelRunSkill");
|
||||
case "remember":
|
||||
return t("approval.toolLabelRemember");
|
||||
case "forget":
|
||||
return t("approval.toolLabelForget");
|
||||
case "sandbox_escape":
|
||||
return t("approval.toolLabelSandboxEscape");
|
||||
case "plan_mode_read_only_command":
|
||||
return t("approval.toolLabelPlanModeReadOnly");
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
|
||||
const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
|
||||
const planModeMcpEnglishSubject = /^MCP (.+) as read-only for planning and research$/;
|
||||
const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
|
||||
|
||||
function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
|
||||
const trimmed = subject.trim();
|
||||
if (tool === "sandbox_escape") {
|
||||
if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
|
||||
if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
|
||||
return `${t("approval.sandboxEscapeSubjectPrefix")}${trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
if (tool === "remember") {
|
||||
return trimmed
|
||||
.replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
|
||||
.replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
|
||||
}
|
||||
if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
|
||||
return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
|
||||
}
|
||||
const mcpTrust = trimmed.match(planModeMcpEnglishSubject);
|
||||
if (mcpTrust) {
|
||||
return t("approval.planModeMcpTrustSubject", { target: mcpTrust[1] ?? "" });
|
||||
}
|
||||
const bashTrust = trimmed.match(planModeBashEnglishSubject);
|
||||
if (bashTrust) {
|
||||
return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
|
||||
const trimmed = reason?.trim() ?? "";
|
||||
if (tool !== "sandbox_escape") return trimmed;
|
||||
if (trimmed.includes("could not wrap this command")) return t("approval.sandboxEscapeWrapReason");
|
||||
if (trimmed.includes("failed while starting this command") || trimmed.includes("Run this command unconfined once?")) {
|
||||
return t("approval.sandboxEscapeRuntimeReason");
|
||||
}
|
||||
return trimmed || t("approval.sandboxEscapeRuntimeReason");
|
||||
}
|
||||
|
||||
function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
|
||||
if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
|
||||
return t("approval.planModeBashTrustReason");
|
||||
}
|
||||
if (reason.includes("external read-only hints need your confirmation")) {
|
||||
return t("approval.planModeMcpTrustReason");
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
@@ -52,13 +134,14 @@ export function ApprovalModal({
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
|
||||
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
|
||||
const toolLabel = approvalToolLabel(approval.tool, t);
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const subject = approval.subject.trim();
|
||||
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
|
||||
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
|
||||
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(approval.reason || subject);
|
||||
const toolMeta = reason || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(reason || subject);
|
||||
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
|
||||
const [revisionOpen, setRevisionOpen] = useState(false);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
@@ -100,6 +183,9 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
|
||||
else if (hasFreshSessionGrant && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (hasFreshSessionGrant && key === "3") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
@@ -107,6 +193,12 @@ export function ApprovalModal({
|
||||
else if (key === "Escape") answerWithExit(onStop);
|
||||
};
|
||||
|
||||
const selectedToolActionKey = (index: number) => {
|
||||
if (!isFreshHumanApproval) return String(index + 1);
|
||||
if (hasFreshSessionGrant) return index === 0 ? "1" : index === 1 ? "2" : "3";
|
||||
return index === 0 ? "1" : "2";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
cardRef.current?.focus();
|
||||
setRevisionOpen(false);
|
||||
@@ -116,7 +208,7 @@ export function ApprovalModal({
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -137,8 +229,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
|
||||
else chooseToolAction(key);
|
||||
else chooseToolAction(selectedToolActionKey(selectedIndexRef.current));
|
||||
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (isPlanApproval) choosePlanAction(event.key);
|
||||
@@ -147,7 +238,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) {
|
||||
@@ -318,7 +409,14 @@ export function ApprovalModal({
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
{isFreshHumanApproval ? (
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
hasFreshSessionGrant ? (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowSandboxEscapeSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} />
|
||||
</>
|
||||
) : (
|
||||
<PromptAction keyLabel="2" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
@@ -331,7 +429,7 @@ export function ApprovalModal({
|
||||
>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
|
||||
@@ -126,24 +126,40 @@ export function contextCostDisplay({
|
||||
sessionCurrency?: string;
|
||||
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
|
||||
}): { amount: number; currency?: string } {
|
||||
// Session-scoped sources only: this value renders under the 会话费用 label,
|
||||
// and falling back to a single request's usage.cost silently displayed one
|
||||
// turn's spend as the whole session's. usage now contributes currency only.
|
||||
if (info?.sessionCost && info.sessionCost > 0) {
|
||||
return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
|
||||
}
|
||||
if (sessionCost && sessionCost > 0) {
|
||||
return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
|
||||
}
|
||||
if (usage?.cost && usage.cost > 0) {
|
||||
return { amount: usage.cost, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
|
||||
}
|
||||
if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
|
||||
return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
|
||||
}
|
||||
if (usage?.costUsd && usage.costUsd > 0) {
|
||||
return { amount: usage.costUsd, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
|
||||
}
|
||||
return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
|
||||
}
|
||||
|
||||
// contextSessionCache picks the session-cumulative cache hit/miss pair for the
|
||||
// panel's session average. All-sources telemetry (panel info, then ContextInfo)
|
||||
// wins over the wire session counters, which the Go agent scopes to the
|
||||
// executor only — the same preference StatusBar applies — and the pair always
|
||||
// comes from a single source so the computed rate never mixes scopes.
|
||||
export function contextSessionCache(
|
||||
info?: Pick<ContextPanelInfo, "sessionCacheHitTokens" | "sessionCacheMissTokens"> | null,
|
||||
context?: Pick<ContextInfo, "cacheHitTokens" | "cacheMissTokens">,
|
||||
usage?: Pick<WireUsage, "sessionCacheHitTokens" | "sessionCacheMissTokens">,
|
||||
): { hit: number; miss: number } {
|
||||
const infoHit = info?.sessionCacheHitTokens ?? 0;
|
||||
const infoMiss = info?.sessionCacheMissTokens ?? 0;
|
||||
if (infoHit + infoMiss > 0) return { hit: infoHit, miss: infoMiss };
|
||||
const ctxHit = context?.cacheHitTokens ?? 0;
|
||||
const ctxMiss = context?.cacheMissTokens ?? 0;
|
||||
if (ctxHit + ctxMiss > 0) return { hit: ctxHit, miss: ctxMiss };
|
||||
return { hit: usage?.sessionCacheHitTokens ?? 0, miss: usage?.sessionCacheMissTokens ?? 0 };
|
||||
}
|
||||
|
||||
interface ContextBreakdown {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
@@ -354,11 +370,12 @@ export function ContextPanel({
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative values for the top summary.
|
||||
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
|
||||
// the Go agent (includes background tasks) and arrives with every usage event.
|
||||
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
// Session-cumulative cache tokens for the top summary: all-sources telemetry
|
||||
// first (matching the session cost and per-source rows in this panel — the
|
||||
// wire session counters are executor-only), with the live counters bridging
|
||||
// only a fresh session's first turn before the telemetry refresh. Hit and
|
||||
// miss come as a pair from one source so the rate cannot mix scopes.
|
||||
const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
|
||||
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
|
||||
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
|
||||
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
|
||||
|
||||
@@ -557,6 +557,7 @@ export function TurnActions({
|
||||
actionPending = false,
|
||||
rewindDisabled = false,
|
||||
hoverMenus = false,
|
||||
isLastTurn = false,
|
||||
}: {
|
||||
text: string;
|
||||
turn?: number;
|
||||
@@ -567,6 +568,8 @@ export function TurnActions({
|
||||
actionPending?: boolean;
|
||||
rewindDisabled?: boolean;
|
||||
hoverMenus?: boolean;
|
||||
/** true when this is the last user turn — disables "summarize after" */
|
||||
isLastTurn?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [confirmScope, setConfirmScope] = useState<MessageActionScope | null>(null);
|
||||
@@ -577,6 +580,9 @@ export function TurnActions({
|
||||
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
|
||||
return t("rewind.disabledNoBoundary");
|
||||
}
|
||||
if (scope === "summ-from" && isLastTurn) {
|
||||
return t("rewind.disabledNoLater");
|
||||
}
|
||||
if (scope === "summ-upto") {
|
||||
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
|
||||
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
|
||||
|
||||
@@ -162,11 +162,11 @@ export function SettingsPanel({
|
||||
setWarning(result.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
setErr(formatSettingsError(e, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [reload, onChanged]);
|
||||
}, [reload, onChanged, t]);
|
||||
const backgroundApply = useCallback(async (fn: () => Promise<void>) => {
|
||||
setErr(null);
|
||||
setWarning(null);
|
||||
@@ -175,9 +175,9 @@ export function SettingsPanel({
|
||||
const next = await reload();
|
||||
onChanged(next);
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
setErr(formatSettingsError(e, t));
|
||||
}
|
||||
}, [reload, onChanged]);
|
||||
}, [reload, onChanged, t]);
|
||||
const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
|
||||
const snapped = snapZoom(zoom);
|
||||
const seq = ++zoomSaveSeq.current;
|
||||
@@ -189,10 +189,10 @@ export function SettingsPanel({
|
||||
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
|
||||
} catch (e) {
|
||||
if (seq !== zoomSaveSeq.current) return;
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
setErr(formatSettingsError(e, t));
|
||||
setZoomPct(zoomToPercent(getRestartZoom()));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
// Close on Esc
|
||||
useEffect(() => {
|
||||
@@ -769,17 +769,38 @@ function sortedJSONValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateProviderExtraBodyValue(value: unknown, path = "extra_body"): void {
|
||||
function formatSettingsError(error: unknown, t: ReturnType<typeof useT>): string {
|
||||
const msg = String((error as Error)?.message ?? error ?? "").trim();
|
||||
const unknownModel = /^unknown model (.+)$/i.exec(msg);
|
||||
if (unknownModel) return t("settings.errorUnknownModel", { model: unknownModel[1] });
|
||||
const providerNotAdded = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
|
||||
if (providerNotAdded) return t("settings.errorModelProviderMissing", { model: providerNotAdded[1], provider: providerNotAdded[2] });
|
||||
const providerNoKey = /^model (.+) is not available because provider (.+) has no key$/i.exec(msg);
|
||||
if (providerNoKey) return t("settings.errorModelProviderNoKey", { model: providerNoKey[1], provider: providerNoKey[2] });
|
||||
const removeAccessBusy = /^finish or cancel active work using (.+) before removing the provider access$/i.exec(msg);
|
||||
if (removeAccessBusy) return t("settings.errorRemoveAccessBusy", { provider: removeAccessBusy[1] });
|
||||
const deleteProviderBusy = /^finish or cancel active work using (.+) before deleting the provider$/i.exec(msg);
|
||||
if (deleteProviderBusy) return t("settings.errorDeleteProviderBusy", { provider: deleteProviderBusy[1] });
|
||||
const saveBeforeRemoveAccess = /^save current session before removing provider access: (.+)$/is.exec(msg);
|
||||
if (saveBeforeRemoveAccess) return t("settings.errorSaveBeforeRemoveAccess", { err: saveBeforeRemoveAccess[1] });
|
||||
const saveBeforeDeleteProvider = /^save current session before deleting provider: (.+)$/is.exec(msg);
|
||||
if (saveBeforeDeleteProvider) return t("settings.errorSaveBeforeDeleteProvider", { err: saveBeforeDeleteProvider[1] });
|
||||
const removeProviderUsed = /^remove provider: (.+) is used by open tabs and no other configured provider exists$/i.exec(msg);
|
||||
if (removeProviderUsed) return t("settings.errorRemoveProviderNoFallback", { provider: removeProviderUsed[1] });
|
||||
return msg || t("settings.errorUnknown");
|
||||
}
|
||||
|
||||
function validateProviderExtraBodyValue(value: unknown, path = "extra_body", t?: ReturnType<typeof useT>): void {
|
||||
if (value === null) {
|
||||
throw new Error(`${path} cannot contain null`);
|
||||
throw new Error(t ? t("settings.providerExtraBodyNull", { path }) : `${path} cannot contain null`);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`));
|
||||
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`, t));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
validateProviderExtraBodyValue(child, `${path}.${key}`);
|
||||
validateProviderExtraBodyValue(child, `${path}.${key}`, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,14 +816,14 @@ export function formatProviderExtraBody(extraBody: Record<string, unknown> | nul
|
||||
return JSON.stringify(sortedJSONValue(cleaned), null, 2);
|
||||
}
|
||||
|
||||
export function parseProviderExtraBody(raw: string): Record<string, unknown> {
|
||||
export function parseProviderExtraBody(raw: string, t?: ReturnType<typeof useT>): Record<string, unknown> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("extra body must be a JSON object");
|
||||
throw new Error(t ? t("settings.providerExtraBodyObjectRequired") : "extra body must be a JSON object");
|
||||
}
|
||||
validateProviderExtraBodyValue(parsed);
|
||||
validateProviderExtraBodyValue(parsed, "extra_body", t);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const key = rawKey.trim();
|
||||
@@ -811,6 +832,12 @@ export function parseProviderExtraBody(raw: string): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function providerExtraBodyParseError(error: unknown, t: ReturnType<typeof useT>): string {
|
||||
if (error instanceof SyntaxError) return t("settings.providerExtraBodyError");
|
||||
const message = String((error as Error)?.message ?? error ?? "").trim();
|
||||
return message || t("settings.providerExtraBodyError");
|
||||
}
|
||||
|
||||
function providerModelFetchFallbackMessage(error: unknown, t: ReturnType<typeof useT>): string {
|
||||
const message = String((error as Error)?.message ?? error);
|
||||
if (/\bstatus\s+(401|403)\b/i.test(message)) {
|
||||
@@ -5591,9 +5618,9 @@ function ProviderEditor({
|
||||
const effectiveHeaders = parseProviderHeaders(headersDraft);
|
||||
const extraBodyParse = useMemo(() => {
|
||||
try {
|
||||
return { value: parseProviderExtraBody(extraBodyDraft), error: "" };
|
||||
} catch {
|
||||
return { value: {}, error: t("settings.providerExtraBodyError") };
|
||||
return { value: parseProviderExtraBody(extraBodyDraft, t), error: "" };
|
||||
} catch (e) {
|
||||
return { value: {}, error: providerExtraBodyParseError(e, t) };
|
||||
}
|
||||
}, [extraBodyDraft, t]);
|
||||
const effectiveExtraBody = extraBodyParse.value;
|
||||
@@ -6218,7 +6245,7 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
|
||||
const parseHooksEditorJSON = (raw = jsonText): { hooks: HookConfigView[]; text: string } | null => {
|
||||
try {
|
||||
const hooks = parseHooksJSON(raw, view?.events ?? []);
|
||||
const hooks = parseHooksJSON(raw, view?.events ?? [], t);
|
||||
const text = formatHooksJSON(hooks, view?.events ?? []);
|
||||
setJsonText(text);
|
||||
setJsonError(null);
|
||||
@@ -6409,7 +6436,7 @@ function formatHooksJSON(hooks: HookConfigView[], eventOrder: string[]): string
|
||||
return JSON.stringify({ hooks: ordered }, null, 2);
|
||||
}
|
||||
|
||||
function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
|
||||
function parseHooksJSON(raw: string, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
let parsed: unknown;
|
||||
@@ -6419,21 +6446,21 @@ function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
|
||||
throw new Error(String((e as Error)?.message ?? e));
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents))).filter((h) => h.event);
|
||||
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents, t))).filter((h) => h.event);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("expected an object or array");
|
||||
throw new Error(t("settings.hooksJsonExpectedObjectArray"));
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const hooksValue = obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) ? obj.hooks : obj;
|
||||
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents);
|
||||
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents, t);
|
||||
}
|
||||
|
||||
function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigView {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("hook item must be an object");
|
||||
function parseHookArrayItem(item: unknown, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonItemObject"));
|
||||
const obj = item as Record<string, unknown>;
|
||||
const event = stringField(obj, "event") || "PreToolUse";
|
||||
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(`unknown hook event ${event}`);
|
||||
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
|
||||
return {
|
||||
event,
|
||||
match: stringField(obj, "match"),
|
||||
@@ -6444,14 +6471,14 @@ function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigVie
|
||||
};
|
||||
}
|
||||
|
||||
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[]): HookConfigView[] {
|
||||
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
|
||||
const valid = new Set(validEvents);
|
||||
const out: HookConfigView[] = [];
|
||||
for (const [event, value] of Object.entries(hooks)) {
|
||||
if (valid.size > 0 && !valid.has(event)) throw new Error(`unknown hook event ${event}`);
|
||||
if (valid.size > 0 && !valid.has(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
|
||||
const items = Array.isArray(value) ? value : [value];
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`hook ${event} item must be an object`);
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonEventItemObject", { event }));
|
||||
const obj = item as Record<string, unknown>;
|
||||
out.push(normalizeHookConfig({
|
||||
event,
|
||||
|
||||
@@ -22,9 +22,10 @@ function nowRate(u?: WireUsage): string | null {
|
||||
}
|
||||
|
||||
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
|
||||
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
|
||||
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
|
||||
// drag the average down); it overtakes only when compaction craters single turns.
|
||||
// turn — but scoped to the EXECUTOR agent only: the wire session counters come
|
||||
// from the main agent and exclude subagent/planner/auxiliary requests. It is
|
||||
// only the pre-first-refresh fallback; the authoritative all-sources number is
|
||||
// contextAvgRate below, so the "session average" label reports one scope.
|
||||
function avgRate(u?: WireUsage): string | null {
|
||||
if (!u) return null;
|
||||
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
|
||||
@@ -32,8 +33,10 @@ function avgRate(u?: WireUsage): string | null {
|
||||
}
|
||||
|
||||
// contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
|
||||
// cache tokens (loaded from persisted telemetry on session resume). Used as a
|
||||
// fallback when no live WireUsage is available yet.
|
||||
// cache tokens — the tab telemetry that accumulates ALL request sources
|
||||
// (executor, subagents, planner, auxiliary calls), refreshed at turn
|
||||
// boundaries. Preferred over avgRate: it matches the 会话费用 tooltip's
|
||||
// "includes main model, subagents and auxiliary calls" scope.
|
||||
function contextAvgRate(ctx: ContextInfo): string | null {
|
||||
const hit = ctx.cacheHitTokens ?? 0;
|
||||
const miss = ctx.cacheMissTokens ?? 0;
|
||||
@@ -186,7 +189,9 @@ export function StatusBar({
|
||||
const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
|
||||
const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
|
||||
const nowPct = nowRate(usage);
|
||||
const avgPct = avgRate(usage) ?? contextAvgRate(context);
|
||||
// All-sources telemetry first; the executor-only live counters only bridge
|
||||
// the gap before the first ContextInfo refresh of a fresh session.
|
||||
const avgPct = contextAvgRate(context) ?? avgRate(usage);
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
|
||||
const costLabel = formatMoneyLocalized(cost, currency, { locale });
|
||||
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, Compass } from "lucide-react";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -127,7 +127,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
aria-expanded={hasBody ? open : undefined}
|
||||
>
|
||||
<span className="tool__label-group">
|
||||
{hasNested && <span className="tool__nested-count">⊞{nested.length}</span>}
|
||||
{hasNested && (
|
||||
<span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
|
||||
<Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<span>{nested.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
|
||||
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
|
||||
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isReadOnlyTool } from "../lib/useController";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useScrollManager } from "../lib/useScrollManager";
|
||||
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
|
||||
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
|
||||
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
@@ -315,6 +315,7 @@ export function Transcript({
|
||||
}, [openAction]);
|
||||
|
||||
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
|
||||
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
|
||||
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
|
||||
|
||||
// ── JumpBar integration ───────────────────────────────────────────────────
|
||||
@@ -391,6 +392,7 @@ export function Transcript({
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
hoverMenus={actionHoverMenus}
|
||||
isLastTurn={turn === lastTurn}
|
||||
onRewind={(targetTurn, scope) => {
|
||||
onRewind?.(targetTurn, scope);
|
||||
setOpenAction(null);
|
||||
@@ -594,7 +596,7 @@ export function Transcript({
|
||||
if (!running) pushTurnActions();
|
||||
}
|
||||
return out;
|
||||
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
|
||||
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn]);
|
||||
|
||||
// ── Assemble rendered output ──────────────────────────────────────────────
|
||||
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
|
||||
@@ -632,6 +634,7 @@ export function Transcript({
|
||||
warmSubcalls={subcallsByParent}
|
||||
warmUserTurn={userTurn}
|
||||
warmCheckpoints={checkpointsByTurn}
|
||||
warmLastTurn={lastTurn}
|
||||
warmOpenAction={openAction}
|
||||
warmActionPending={actionPending}
|
||||
warmRewindDisabled={rewindDisabled}
|
||||
@@ -687,6 +690,7 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSubcalls,
|
||||
warmUserTurn,
|
||||
warmCheckpoints,
|
||||
warmLastTurn,
|
||||
warmOpenAction,
|
||||
warmActionPending,
|
||||
warmRewindDisabled,
|
||||
@@ -709,6 +713,7 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSubcalls: ReadonlyMap<string, ToolItem[]>;
|
||||
warmUserTurn: ReadonlyMap<string, number>;
|
||||
warmCheckpoints: ReadonlyMap<number, CheckpointMeta>;
|
||||
warmLastTurn?: number;
|
||||
warmOpenAction: OpenTurnAction | null;
|
||||
warmActionPending: boolean;
|
||||
warmRewindDisabled: boolean;
|
||||
@@ -774,6 +779,7 @@ const WarmZone = memo(function WarmZone({
|
||||
onEdit={warmOnEdit}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
lastTurn={warmLastTurn}
|
||||
/>
|
||||
</WarmTurnCard>,
|
||||
);
|
||||
@@ -821,6 +827,7 @@ function WarmTurnItems({
|
||||
onEdit,
|
||||
tabId,
|
||||
creationMode = false,
|
||||
lastTurn,
|
||||
}: {
|
||||
startIdx: number;
|
||||
endIdx: number;
|
||||
@@ -837,6 +844,7 @@ function WarmTurnItems({
|
||||
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
|
||||
tabId?: string;
|
||||
creationMode?: boolean;
|
||||
lastTurn?: number;
|
||||
}) {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let actionText = "";
|
||||
@@ -857,6 +865,7 @@ function WarmTurnItems({
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
hoverMenus={actionHoverMenus}
|
||||
isLastTurn={turn === lastTurn}
|
||||
onRewind={(targetTurn, scope) => {
|
||||
onRewind?.(targetTurn, scope);
|
||||
setOpenAction(null);
|
||||
|
||||
@@ -719,12 +719,13 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
|
||||
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
|
||||
}
|
||||
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
|
||||
if (typeof window === "undefined") return "demo";
|
||||
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
|
||||
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
|
||||
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
|
||||
if (value === "running" || value === "busy" || value === "streaming") return "running";
|
||||
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
@@ -819,8 +820,8 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
|
||||
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
|
||||
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
|
||||
@@ -872,6 +873,7 @@ function makeMockApp(): AppBindings {
|
||||
const freshMock = scenario === "fresh";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const sandboxEscapeMock = scenario === "sandbox_escape";
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
@@ -1634,6 +1636,23 @@ function makeMockApp(): AppBindings {
|
||||
cwd: "~/projects/joyquant-db",
|
||||
},
|
||||
];
|
||||
if (sandboxEscapeMock) {
|
||||
window.setTimeout(() => {
|
||||
if (pendingApprovalPreview) return;
|
||||
pendingApprovalPreview = true;
|
||||
emitMockTurnStarted();
|
||||
emit({ kind: "reasoning", text: t("mock.sandboxEscapeReasoning") });
|
||||
emit({
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "mock-sandbox-escape-preview",
|
||||
tool: "sandbox_escape",
|
||||
subject: t("mock.sandboxEscapeSubject"),
|
||||
reason: t("mock.sandboxEscapeReason"),
|
||||
},
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
const mockModelCatalog = [
|
||||
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
|
||||
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
|
||||
@@ -1730,6 +1749,26 @@ function makeMockApp(): AppBindings {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
trimmedInput === "/sandbox-escape-preview" ||
|
||||
trimmedInput === "sandbox escape preview" ||
|
||||
trimmedInput === "sandbox_escape preview" ||
|
||||
trimmedInput === "sandbox escape预览"
|
||||
) {
|
||||
pendingApprovalPreview = true;
|
||||
await delay(250);
|
||||
if (cancelled) return;
|
||||
emit({
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "mock-sandbox-escape-preview",
|
||||
tool: "sandbox_escape",
|
||||
subject: t("mock.sandboxEscapeSubject"),
|
||||
reason: t("mock.sandboxEscapeReason"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
trimmedInput === "/plan-approve-preview" ||
|
||||
trimmedInput === "plan approve preview" ||
|
||||
@@ -1844,6 +1883,67 @@ function makeMockApp(): AppBindings {
|
||||
emitMockTurnDone();
|
||||
return;
|
||||
}
|
||||
if (trimmedInput === "/nested-preview" || trimmedInput === "nested preview" || trimmedInput === "嵌套预览") {
|
||||
const parentId = "mock-nested-explore";
|
||||
await delay(180);
|
||||
if (cancelled) return;
|
||||
emit({
|
||||
kind: "reasoning",
|
||||
text: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
|
||||
});
|
||||
emit({
|
||||
kind: "message",
|
||||
text: "",
|
||||
reasoning: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
|
||||
});
|
||||
emit({
|
||||
kind: "tool_dispatch",
|
||||
tool: {
|
||||
id: parentId,
|
||||
name: "explore",
|
||||
args: JSON.stringify({ task: "在 Reasonix 前端中检查工具调用图标和嵌套调用展示" }),
|
||||
readOnly: true,
|
||||
profile: { model: "mock-reasonix", effort: "high" },
|
||||
},
|
||||
});
|
||||
for (let i = 1; i <= 30; i += 1) {
|
||||
if (cancelled) return;
|
||||
const id = `mock-nested-${i}`;
|
||||
const isSearch = i % 3 === 0;
|
||||
const name = isSearch ? "grep" : "read_file";
|
||||
const args = isSearch
|
||||
? { pattern: i % 2 === 0 ? "tool__nested-count" : "explore", path: "desktop/frontend/src" }
|
||||
: { path: `desktop/frontend/src/${i % 2 === 0 ? "components/ToolCard.tsx" : "styles.css"}`, offset: i * 10, limit: 40 };
|
||||
emit({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify(args), readOnly: true, parentId } });
|
||||
emit({
|
||||
kind: "tool_result",
|
||||
tool: {
|
||||
id,
|
||||
name,
|
||||
readOnly: true,
|
||||
output: isSearch ? "3 matches" : "read 40 lines",
|
||||
durationMs: 24 + i,
|
||||
},
|
||||
});
|
||||
await delay(18);
|
||||
}
|
||||
emit({
|
||||
kind: "tool_result",
|
||||
tool: {
|
||||
id: parentId,
|
||||
name: "explore",
|
||||
readOnly: true,
|
||||
output: "已读 20 个文件 · 搜索 10 个文件",
|
||||
durationMs: 61510,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
kind: "message",
|
||||
text: "Mock nested tool preview complete. The explore row now shows the compass count marker.",
|
||||
});
|
||||
emitMockTurnDone();
|
||||
return;
|
||||
}
|
||||
// Simulate the server's pre-first-token latency so the deferred user bubble
|
||||
// and the "un-send on Esc before any reply" path are observable in browser
|
||||
// dev. Bail if cancelled during the wait — nothing was streamed yet.
|
||||
|
||||
@@ -74,6 +74,14 @@ export function questionTurnsById(questions: QuestionAnchor[]): Map<string, numb
|
||||
return turns;
|
||||
}
|
||||
|
||||
export function lastQuestionTurn(questions: readonly QuestionAnchor[], turns: ReadonlyMap<string, number>): number | undefined {
|
||||
for (let i = questions.length - 1; i >= 0; i -= 1) {
|
||||
const turn = turns.get(questions[i].id);
|
||||
if (turn != null) return turn;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function scrollVersion(items: Item[]): string {
|
||||
return items
|
||||
.map((it) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { app, onEvent, onReady } from "./bridge";
|
||||
import { invalidateCache } from "./composerHistory";
|
||||
import { formatGuardianAssessmentNotice } from "./guardianEvents";
|
||||
import { createRafBatch } from "./rafBatch";
|
||||
import { t } from "./i18n";
|
||||
import { t, type DictKey } from "./i18n";
|
||||
import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
|
||||
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
|
||||
import type {
|
||||
@@ -244,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
|
||||
|
||||
const STALE_TURN_RECONCILE_MS = 30_000;
|
||||
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
|
||||
const STARTUP_READY_META_RECONCILE_MS = 250;
|
||||
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
|
||||
|
||||
export function shouldReconcileStaleTurn(
|
||||
state: Pick<State, "running" | "turnActive"> | undefined,
|
||||
@@ -379,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
const positionalResults = positionalToolResults(messages);
|
||||
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
|
||||
|
||||
const items: Item[] = [];
|
||||
let items: Item[] = [];
|
||||
let seq = startSeq;
|
||||
const consumedToolIDs = new Set<string>();
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
@@ -394,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
}
|
||||
if (m.role === "notice") {
|
||||
if (m.content.trim() !== "") {
|
||||
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: m.content });
|
||||
seq++;
|
||||
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -725,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
|
||||
}
|
||||
case "notice":
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: e.text ?? "" }] };
|
||||
return appendNoticeToState(s, e.level ?? "info", e.text ?? "");
|
||||
case "phase":
|
||||
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
|
||||
case "compaction_started":
|
||||
@@ -954,24 +957,178 @@ function errorMessage(err: unknown): string {
|
||||
}
|
||||
|
||||
export function effortSwitchNoticeText(err: unknown): string {
|
||||
return settingSwitchNoticeText(err, "effort", {
|
||||
busy: "status.effortSwitchBusy",
|
||||
leaseHeld: "status.effortSwitchLeaseHeld",
|
||||
starting: "status.effortSwitchStarting",
|
||||
startupFailed: "status.effortSwitchStartupFailed",
|
||||
retry: "status.effortSwitchRetry",
|
||||
failed: "status.effortSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
export function modelSwitchNoticeText(err: unknown): string {
|
||||
const msg = errorMessage(err).trim() || "unknown error";
|
||||
const unknownModel = /^unknown model (.+)$/i.exec(msg);
|
||||
if (unknownModel) {
|
||||
return t("status.modelSwitchUnknown", { model: unknownModel[1] });
|
||||
}
|
||||
const unavailable = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
|
||||
if (unavailable) {
|
||||
return t("status.modelSwitchProviderUnavailable", { model: unavailable[1], provider: unavailable[2] });
|
||||
}
|
||||
return settingSwitchNoticeText(msg, "model", {
|
||||
busy: "status.modelSwitchBusy",
|
||||
leaseHeld: "status.modelSwitchLeaseHeld",
|
||||
starting: "status.modelSwitchStarting",
|
||||
startupFailed: "status.modelSwitchStartupFailed",
|
||||
retry: "status.modelSwitchRetry",
|
||||
failed: "status.modelSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
export function tokenModeSwitchNoticeText(err: unknown): string {
|
||||
return settingSwitchNoticeText(err, "token mode", {
|
||||
busy: "status.tokenModeSwitchBusy",
|
||||
leaseHeld: "status.tokenModeSwitchLeaseHeld",
|
||||
starting: "status.tokenModeSwitchStarting",
|
||||
startupFailed: "status.tokenModeSwitchStartupFailed",
|
||||
retry: "status.tokenModeSwitchRetry",
|
||||
failed: "status.tokenModeSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
export function localizedBackendNoticeText(text: string): string {
|
||||
const msg = text.trim();
|
||||
const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
|
||||
if (autosave) {
|
||||
return t("status.sessionAutosaveFailed", { err: autosave[1] });
|
||||
}
|
||||
const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
|
||||
if (saveBefore) {
|
||||
return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
|
||||
}
|
||||
const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
|
||||
if (modelFallback) {
|
||||
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
|
||||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeSavedCopy");
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeKeptCurrent");
|
||||
}
|
||||
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
|
||||
return t("recovery.noticeAdoptedCovered");
|
||||
}
|
||||
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
|
||||
return t("recovery.noticeAdopted");
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function recoveryNoticeDedupeKey(text: string): string {
|
||||
const msg = text.trim();
|
||||
if (
|
||||
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
|
||||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
|
||||
msg === t("recovery.noticeSavedCopy")
|
||||
) {
|
||||
return "recovery:saved-copy";
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
|
||||
msg === t("recovery.noticeKeptCurrent")
|
||||
) {
|
||||
return "recovery:kept-current";
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
|
||||
msg === t("recovery.noticeAdoptedCovered")
|
||||
) {
|
||||
return "recovery:adopted-covered";
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
|
||||
msg === t("recovery.noticeAdopted")
|
||||
) {
|
||||
return "recovery:adopted";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string): { items: Item[]; seq: number } {
|
||||
const text = localizedBackendNoticeText(rawText);
|
||||
const key = recoveryNoticeDedupeKey(rawText) || recoveryNoticeDedupeKey(text);
|
||||
if (key && items.some((item) => item.kind === "notice" && recoveryNoticeDedupeKey(item.text) === key)) {
|
||||
return { items, seq };
|
||||
}
|
||||
return { items: [...items, { kind: "notice", id, level, text }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text);
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
|
||||
}
|
||||
|
||||
function localizedSessionAction(action: string): string {
|
||||
switch (action.trim()) {
|
||||
case "changing model":
|
||||
return t("status.actionChangingModel");
|
||||
case "changing effort":
|
||||
return t("status.actionChangingEffort");
|
||||
case "changing token mode":
|
||||
return t("status.actionChangingTokenMode");
|
||||
case "rebuilding settings":
|
||||
return t("status.actionRebuildingSettings");
|
||||
case "switching sessions":
|
||||
return t("status.actionSwitchingSessions");
|
||||
case "switching tabs":
|
||||
return t("status.actionSwitchingTabs");
|
||||
case "autosave":
|
||||
return t("status.actionAutosave");
|
||||
default:
|
||||
return action.trim() || t("status.actionCurrentSession");
|
||||
}
|
||||
}
|
||||
|
||||
function settingSwitchNoticeText(
|
||||
err: unknown,
|
||||
setting: "effort" | "model" | "token mode",
|
||||
keys: {
|
||||
busy: DictKey;
|
||||
leaseHeld: DictKey;
|
||||
starting: DictKey;
|
||||
startupFailed: DictKey;
|
||||
retry: DictKey;
|
||||
failed: DictKey;
|
||||
},
|
||||
): string {
|
||||
const msg = errorMessage(err).trim() || "unknown error";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("finish or cancel") && lower.includes("before changing effort")) {
|
||||
return t("status.effortSwitchBusy");
|
||||
if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
|
||||
return t(keys.busy);
|
||||
}
|
||||
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
|
||||
return t("status.effortSwitchLeaseHeld");
|
||||
return t(keys.leaseHeld);
|
||||
}
|
||||
if (lower.includes("workspace is still starting")) {
|
||||
return t("status.effortSwitchStarting");
|
||||
return t(keys.starting);
|
||||
}
|
||||
if (lower.startsWith("workspace failed to start")) {
|
||||
return t("status.effortSwitchStartupFailed", { err: msg });
|
||||
return t(keys.startupFailed, { err: msg });
|
||||
}
|
||||
if (lower.includes("changed while switching effort") || (lower.includes("tab ") && lower.includes("not found"))) {
|
||||
return t("status.effortSwitchRetry");
|
||||
if (lower.includes(`changed while switching ${setting}`) || (lower.includes("tab ") && lower.includes("not found"))) {
|
||||
return t(keys.retry);
|
||||
}
|
||||
return t("status.effortSwitchFailed", { err: msg });
|
||||
return t(keys.failed, { err: msg });
|
||||
}
|
||||
|
||||
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
|
||||
@@ -984,6 +1141,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
|
||||
try {
|
||||
const meta = await app.MetaForTab(tabId);
|
||||
dispatchTo(tabId, { type: "meta", meta });
|
||||
return meta;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
|
||||
if (!activeTabId) return;
|
||||
void replay().catch(() => {});
|
||||
@@ -1005,6 +1172,8 @@ export function useController() {
|
||||
const stateRef = useRef(activeState);
|
||||
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
|
||||
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
|
||||
const readyMetaReconcileSeq = useRef(0);
|
||||
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
|
||||
activeTabIdRef.current = activeTabId;
|
||||
stateRef.current = activeState;
|
||||
|
||||
@@ -1391,11 +1560,9 @@ export function useController() {
|
||||
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
|
||||
return;
|
||||
}
|
||||
if (activeId) {
|
||||
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
|
||||
return;
|
||||
}
|
||||
void syncActiveTabFromBackend(false, true);
|
||||
// A ready event can race the initial hydrate. Refresh the tab metadata
|
||||
// first so a stale ready=false snapshot does not keep the composer locked.
|
||||
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
|
||||
});
|
||||
|
||||
void syncActiveTabFromBackend(false, true);
|
||||
@@ -1416,6 +1583,51 @@ export function useController() {
|
||||
};
|
||||
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
|
||||
|
||||
// If the startup ready event is missed, keep the composer lock in sync with
|
||||
// the active tab's backend metadata without kicking off tab activation work.
|
||||
useEffect(() => {
|
||||
const tabId = activeTabId;
|
||||
const meta = activeState.meta;
|
||||
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
|
||||
readyMetaReconcileSeq.current += 1;
|
||||
readyMetaReconcileActive.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const seq = readyMetaReconcileSeq.current + 1;
|
||||
readyMetaReconcileSeq.current = seq;
|
||||
readyMetaReconcileActive.current = { tabId, seq };
|
||||
|
||||
const stillCurrent = () => {
|
||||
const active = readyMetaReconcileActive.current;
|
||||
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
|
||||
};
|
||||
|
||||
const schedule = (attempt: number) => {
|
||||
timer = window.setTimeout(() => {
|
||||
void tick(attempt);
|
||||
}, STARTUP_READY_META_RECONCILE_MS);
|
||||
};
|
||||
|
||||
const tick = async (attempt: number) => {
|
||||
if (!stillCurrent()) return;
|
||||
const current = statesRef.current.get(tabId);
|
||||
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
|
||||
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
|
||||
if (!stillCurrent()) return;
|
||||
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
|
||||
schedule(attempt + 1);
|
||||
};
|
||||
|
||||
schedule(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
|
||||
|
||||
// Stale-turn watchdog: if the frontend thinks the agent is running but the
|
||||
// turn stream has gone quiet, reconcile with the backend. This catches cases
|
||||
// where the Wails event channel silently drops turn_done after the final
|
||||
@@ -1448,7 +1660,7 @@ export function useController() {
|
||||
}, [activeTabId]);
|
||||
|
||||
const sendToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText, originalText?: string) => {
|
||||
if (!tabId) throw new Error("workspace is still starting");
|
||||
if (!tabId) throw new Error(t("composer.workspaceStarting"));
|
||||
const seq = getOrCreateState(statesRef.current, tabId).seq;
|
||||
const display = displayText.trim();
|
||||
const submit = submitText.trim();
|
||||
@@ -1474,7 +1686,7 @@ export function useController() {
|
||||
return sendToTab(tabId, displayText, submitText);
|
||||
}
|
||||
return activeTabFromBackend().then((active) => {
|
||||
if (!active?.id) throw new Error("workspace is still starting");
|
||||
if (!active?.id) throw new Error(t("composer.workspaceStarting"));
|
||||
setActiveTabId(active.id);
|
||||
activeTabIdRef.current = active.id;
|
||||
confirmBackendActiveTab(active.id);
|
||||
@@ -1484,7 +1696,7 @@ export function useController() {
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
|
||||
|
||||
const runShell = useCallback(async (command: string) => {
|
||||
if (!activeTabId) throw new Error("workspace is still starting");
|
||||
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
|
||||
dispatchTo(activeTabId, { type: "user", text: `!${command}`, seq: getOrCreateState(statesRef.current, activeTabId).seq });
|
||||
try {
|
||||
await app.RunShellForTab(activeTabId, command);
|
||||
@@ -1495,7 +1707,7 @@ export function useController() {
|
||||
}, [activeTabId, dispatchTo]);
|
||||
|
||||
const steer = useCallback(async (text: string) => {
|
||||
if (!activeTabId) throw new Error("workspace is still starting");
|
||||
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
|
||||
// No optimistic user bubble: rewind/fork map turns by counting user items,
|
||||
// and a steer is not a backend turn — the Steer event's ↪ notice is the
|
||||
// visible confirmation (#3660).
|
||||
@@ -1733,7 +1945,7 @@ export function useController() {
|
||||
try {
|
||||
await app.SetModelForTab(activeTabId, name);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.modelSwitchFailed", { err: errorMessage(err) }) });
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1763,7 +1975,7 @@ export function useController() {
|
||||
try {
|
||||
await app.SetTokenModeForTab(activeTabId, mode);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.tokenModeSwitchFailed", { err: errorMessage(err) }) });
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const en = {
|
||||
"common.busyHint": "Finish or stop the current turn first",
|
||||
"common.loading": "Loading…",
|
||||
"composer.readOnlyChannel": "Channel conversation is read-only",
|
||||
"composer.workspaceStarting": "Workspace is still starting",
|
||||
"app.splashSubtitle": "Agent Workspace",
|
||||
|
||||
// top bar
|
||||
@@ -531,14 +532,37 @@ export const en = {
|
||||
"modelSwitcher.searchPlaceholder": "Search models…",
|
||||
"modelSwitcher.noMatches": "No matching models",
|
||||
"status.noModels": "no switchable models",
|
||||
"status.modelSwitchBusy": "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.modelSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
|
||||
"status.modelSwitchStarting": "This session is still starting. Try changing models again in a moment.",
|
||||
"status.modelSwitchStartupFailed": "This session did not start, so the model cannot change: {err}",
|
||||
"status.modelSwitchRetry": "The current session changed while switching models. Try once more.",
|
||||
"status.modelSwitchUnknown": "Unknown model {model}.",
|
||||
"status.modelSwitchProviderUnavailable": "Model {model} is unavailable because provider {provider} is not added.",
|
||||
"status.modelSwitchFailed": "Model switch failed: {err}",
|
||||
"status.modelFallbackSwitched": "Model {model} is no longer available; switched to {fallback}.",
|
||||
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
|
||||
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
|
||||
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
|
||||
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
|
||||
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
|
||||
"status.tokenModeSwitchBusy": "Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing token saver.",
|
||||
"status.tokenModeSwitchStarting": "This session is still starting. Try changing token saver again in a moment.",
|
||||
"status.tokenModeSwitchStartupFailed": "This session did not start, so token saver cannot change: {err}",
|
||||
"status.tokenModeSwitchRetry": "The current session changed while switching token saver. Try once more.",
|
||||
"status.tokenModeSwitchFailed": "Token saver switch failed: {err}",
|
||||
"status.sessionAutosaveFailed": "Session autosave failed: {err}",
|
||||
"status.sessionSaveFailedBefore": "Session save failed before {action}: {err}",
|
||||
"status.actionChangingModel": "changing models",
|
||||
"status.actionChangingEffort": "changing reasoning effort",
|
||||
"status.actionChangingTokenMode": "changing token saver",
|
||||
"status.actionRebuildingSettings": "refreshing settings",
|
||||
"status.actionSwitchingSessions": "switching sessions",
|
||||
"status.actionSwitchingTabs": "switching tabs",
|
||||
"status.actionAutosave": "autosave",
|
||||
"status.actionCurrentSession": "saving the current session",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "Reasoning effort",
|
||||
"status.effortAutoTitle": "Reasoning effort: auto (model default: {def})",
|
||||
@@ -663,6 +687,29 @@ export const en = {
|
||||
"approval.allowRulePersistent": "Always allow (save)",
|
||||
"approval.allowEditsSession": "Allow edits for session",
|
||||
"approval.allowFilePersistent": "Always allow this file (save)",
|
||||
"approval.allowSandboxEscapeSession": "Use real environment for this session",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "edit file",
|
||||
"approval.toolLabelWriteFile": "write file",
|
||||
"approval.toolLabelMultiEdit": "multi edit",
|
||||
"approval.toolLabelMoveFile": "move file",
|
||||
"approval.toolLabelWebFetch": "web fetch",
|
||||
"approval.toolLabelRunSkill": "run skill",
|
||||
"approval.toolLabelRemember": "remember",
|
||||
"approval.toolLabelForget": "forget",
|
||||
"approval.toolLabelSandboxEscape": "bash sandbox escape",
|
||||
"approval.toolLabelPlanModeReadOnly": "plan-mode read-only command",
|
||||
"approval.memorySaveUpdate": "Save/update memory",
|
||||
"approval.memoryBodyLabel": "body",
|
||||
"approval.memoryArchivePrefix": "Archive memory ",
|
||||
"approval.planModeMcpTrustSubject": "MCP {target} as read-only for planning and research",
|
||||
"approval.planModeMcpTrustReason": "This MCP tool reports read-only, but external read-only hints need your confirmation before plan mode can use them. Choose always allow to remember this trust for future planning and read-only research.",
|
||||
"approval.planModeBashTrustSubject": "Trust {prefix} as a read-only command prefix while planning\nCommand: {command}",
|
||||
"approval.planModeBashTrustReason": "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
"approval.sandboxEscapeSubjectFallback": "run shell command unconfined once",
|
||||
"approval.sandboxEscapeSubjectPrefix": "run unconfined once: ",
|
||||
"approval.sandboxEscapeWrapReason": "Windows sandbox could not wrap this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
|
||||
// ask card
|
||||
"ask.title": "Need your decision",
|
||||
@@ -732,6 +779,7 @@ export const en = {
|
||||
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
|
||||
"history.failedOpenSession": "Cannot open session",
|
||||
"history.failedOpenGlobal": "Cannot open global session",
|
||||
"history.missingWorkspaceRoot": "Cannot open session because its workspace path is missing.",
|
||||
"history.selectSession": "Select a session to preview.",
|
||||
"history.trashSessionActions": "Trash session actions",
|
||||
"history.historySessionActions": "History session actions",
|
||||
@@ -745,9 +793,10 @@ export const en = {
|
||||
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
|
||||
"recovery.reason": "Reason: {reason}",
|
||||
"recovery.digest": "Digest: {digest}",
|
||||
"recovery.parent": "Parent: {parent}",
|
||||
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Projects",
|
||||
@@ -1542,6 +1591,15 @@ export const en = {
|
||||
"settings.modelUsage": "Model usage",
|
||||
"settings.modelUnavailable": "{ref} is not available. Pick a configured model or add provider access.",
|
||||
"settings.modelNeedsKey": "{provider} needs an API key before this model can run.",
|
||||
"settings.errorUnknown": "Operation failed.",
|
||||
"settings.errorUnknownModel": "Unknown model {model}.",
|
||||
"settings.errorModelProviderMissing": "Model {model} is unavailable because provider {provider} is not added.",
|
||||
"settings.errorModelProviderNoKey": "Model {model} is unavailable because provider {provider} has no API key.",
|
||||
"settings.errorRemoveAccessBusy": "Stop active work using {provider} before removing provider access.",
|
||||
"settings.errorDeleteProviderBusy": "Stop active work using {provider} before deleting the provider.",
|
||||
"settings.errorSaveBeforeRemoveAccess": "Could not save the current session before removing provider access: {err}",
|
||||
"settings.errorSaveBeforeDeleteProvider": "Could not save the current session before deleting the provider: {err}",
|
||||
"settings.errorRemoveProviderNoFallback": "Provider {provider} is used by open tabs, and no other configured provider is available.",
|
||||
"settings.customProviders": "Custom providers",
|
||||
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
|
||||
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
|
||||
@@ -1654,6 +1712,8 @@ export const en = {
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
|
||||
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
|
||||
"settings.providerExtraBodyNull": "{path} cannot contain null.",
|
||||
"settings.providerExtraBodyObjectRequired": "Extra body must be a JSON object.",
|
||||
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
|
||||
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
|
||||
"settings.fetchingModels": "Refreshing…",
|
||||
@@ -1736,6 +1796,10 @@ export const en = {
|
||||
"settings.hooksJsonApplied": "JSON validated. Save to persist it.",
|
||||
"settings.hooksJsonFormatted": "JSON formatted. Save to persist it.",
|
||||
"settings.hooksJsonInvalid": "Invalid JSON: {error}",
|
||||
"settings.hooksJsonExpectedObjectArray": "Expected an object or array.",
|
||||
"settings.hooksJsonItemObject": "Hook item must be an object.",
|
||||
"settings.hooksJsonUnknownEvent": "Unknown hook event {event}.",
|
||||
"settings.hooksJsonEventItemObject": "Hook {event} item must be an object.",
|
||||
"settings.hooksJsonClipboardEmpty": "Clipboard is empty",
|
||||
"settings.hooksJsonClipboardUnavailable": "Clipboard is unavailable; copy the textarea manually.",
|
||||
"settings.hooksJsonPasteFailed": "Paste failed: {error}",
|
||||
@@ -1825,6 +1889,7 @@ export const en = {
|
||||
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
|
||||
"rewind.disabledNoCode": "No code changes were captured after this message",
|
||||
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
|
||||
"rewind.disabledNoLater": "There is nothing after this message to summarize",
|
||||
"rewind.filesChanged": "{count} files",
|
||||
"rewind.turnFiles": "{count} this turn",
|
||||
"rewind.both": "Rewind code and conversation",
|
||||
@@ -1845,6 +1910,7 @@ export const en = {
|
||||
"rewind.busyConversation": "Rewinding conversation…",
|
||||
"rewind.busyCode": "Rewinding code…",
|
||||
"rewind.busyBoth": "Rewinding code and conversation…",
|
||||
"rewind.failed": "Rewind failed",
|
||||
"undoRewind.turns": "Rolled back {n} turn(s)",
|
||||
"undoRewind.filesRestored": "{n} file(s) restored",
|
||||
"undoRewind.filesRemoved": "{n} file(s) removed",
|
||||
@@ -2027,6 +2093,9 @@ export const en = {
|
||||
"mock.topicAi": "AI engineering best practices",
|
||||
"mock.topicLab": "Exploratory experiments",
|
||||
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
|
||||
"mock.sandboxEscapeReasoning": "Windows sandbox helper failed; requesting a one-time unconfined rerun approval.",
|
||||
"mock.sandboxEscapeSubject": "run unconfined once: go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
"mock.askQ1Header": "Approach",
|
||||
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
|
||||
"mock.askQ1Opt1Label": "git stash then pull",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"common.busyHint": "請先完成或停止當前回合",
|
||||
"common.loading": "載入中…",
|
||||
"composer.readOnlyChannel": "渠道會話唯讀",
|
||||
"composer.workspaceStarting": "工作區還在啟動",
|
||||
"app.splashSubtitle": "Agent Workspace",
|
||||
|
||||
// 頂欄
|
||||
@@ -385,13 +386,31 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"modelSwitcher.searchPlaceholder": "搜尋模型…",
|
||||
"modelSwitcher.noMatches": "沒有匹配的模型",
|
||||
"status.noModels": "沒有可切換模型",
|
||||
"status.modelSwitchBusy": "暫時不能切換模型。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.modelSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換模型。",
|
||||
"status.modelSwitchStarting": "會話還在啟動,稍後再切換模型。",
|
||||
"status.modelSwitchStartupFailed": "目前會話啟動失敗,無法切換模型:{err}",
|
||||
"status.modelSwitchRetry": "目前會話已變更,請再試一次切換模型。",
|
||||
"status.modelSwitchUnknown": "未知模型 {model}。",
|
||||
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
|
||||
"status.modelSwitchFailed": "模型切換失敗:{err}",
|
||||
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切換到 {fallback}。",
|
||||
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
|
||||
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
|
||||
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
|
||||
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
|
||||
"status.sessionAutosaveFailed": "會話自動儲存失敗:{err}",
|
||||
"status.sessionSaveFailedBefore": "在{action}前儲存會話失敗:{err}",
|
||||
"status.actionChangingModel": "切換模型",
|
||||
"status.actionChangingEffort": "切換推理力度",
|
||||
"status.actionChangingTokenMode": "切換省 token 模式",
|
||||
"status.actionRebuildingSettings": "重新整理設定",
|
||||
"status.actionSwitchingSessions": "切換會話",
|
||||
"status.actionSwitchingTabs": "切換標籤頁",
|
||||
"status.actionAutosave": "自動儲存",
|
||||
"status.actionCurrentSession": "儲存目前會話",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
|
||||
@@ -515,6 +534,29 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"approval.allowRulePersistent": "總是允許(儲存)",
|
||||
"approval.allowEditsSession": "本會話允許編輯",
|
||||
"approval.allowFilePersistent": "總是允許此檔案(儲存)",
|
||||
"approval.allowSandboxEscapeSession": "本會話使用真實環境",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "編輯檔案",
|
||||
"approval.toolLabelWriteFile": "寫入檔案",
|
||||
"approval.toolLabelMultiEdit": "批次編輯",
|
||||
"approval.toolLabelMoveFile": "移動檔案",
|
||||
"approval.toolLabelWebFetch": "讀取網頁",
|
||||
"approval.toolLabelRunSkill": "執行技能",
|
||||
"approval.toolLabelRemember": "儲存記憶",
|
||||
"approval.toolLabelForget": "刪除記憶",
|
||||
"approval.toolLabelSandboxEscape": "bash 沙箱降級核准",
|
||||
"approval.toolLabelPlanModeReadOnly": "計劃模式唯讀命令",
|
||||
"approval.memorySaveUpdate": "儲存/更新記憶",
|
||||
"approval.memoryBodyLabel": "正文",
|
||||
"approval.memoryArchivePrefix": "封存記憶 ",
|
||||
"approval.planModeMcpTrustSubject": "將 MCP {target} 作為計劃/研究唯讀工具信任",
|
||||
"approval.planModeMcpTrustReason": "這個 MCP 工具宣告自己是唯讀的,但外部唯讀提示需要你確認後,計劃模式才能使用。選擇總是允許可為後續計劃和唯讀研究記住這份信任。",
|
||||
"approval.planModeBashTrustSubject": "在計劃模式中信任 {prefix} 為唯讀命令前綴\n命令:{command}",
|
||||
"approval.planModeBashTrustReason": "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
|
||||
"approval.sandboxEscapeSubjectFallback": "僅本次不進沙箱執行 shell 命令",
|
||||
"approval.sandboxEscapeSubjectPrefix": "僅本次不進沙箱執行:",
|
||||
"approval.sandboxEscapeWrapReason": "Windows 沙箱無法包裝這條命令。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
|
||||
// 提問卡片
|
||||
"ask.title": "需要你決定",
|
||||
@@ -575,6 +617,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.failedOpenProject": "無法開啟會話,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "無法開啟會話",
|
||||
"history.failedOpenGlobal": "無法開啟全域會話",
|
||||
"history.missingWorkspaceRoot": "無法開啟會話:缺少工作區路徑。",
|
||||
"history.selectSession": "選擇一個會話以預覽。",
|
||||
"history.trashSessionActions": "回收站會話操作",
|
||||
"history.historySessionActions": "歷史會話操作",
|
||||
@@ -588,9 +631,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父會話:{parent}",
|
||||
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
|
||||
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
|
||||
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
|
||||
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
|
||||
|
||||
// 專案樹
|
||||
"projectTree.workspaceTitle": "專案",
|
||||
@@ -950,6 +994,15 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.modelUsage": "使用模型",
|
||||
"settings.modelUnavailable": "{ref} 不可用。請選擇已設定模型,或先新增模型服務接入。",
|
||||
"settings.modelNeedsKey": "{provider} 需要設定 API key 後才能執行該模型。",
|
||||
"settings.errorUnknown": "操作失敗。",
|
||||
"settings.errorUnknownModel": "未知模型 {model}。",
|
||||
"settings.errorModelProviderMissing": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
|
||||
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因為提供商 {provider} 尚未設定 API Key。",
|
||||
"settings.errorRemoveAccessBusy": "請先停止正在使用 {provider} 的任務,再移除模型服務接入。",
|
||||
"settings.errorDeleteProviderBusy": "請先停止正在使用 {provider} 的任務,再刪除模型服務。",
|
||||
"settings.errorSaveBeforeRemoveAccess": "移除模型服務接入前儲存目前會話失敗:{err}",
|
||||
"settings.errorSaveBeforeDeleteProvider": "刪除模型服務前儲存目前會話失敗:{err}",
|
||||
"settings.errorRemoveProviderNoFallback": "模型服務 {provider} 正被開啟的標籤頁使用,且沒有其他可用模型服務可切換。",
|
||||
"settings.customProviders": "自訂供應商",
|
||||
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
|
||||
@@ -1062,6 +1115,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
|
||||
"settings.providerExtraBodyObjectRequired": "額外請求體必須是 JSON 物件。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
|
||||
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
|
||||
"settings.fetchingModels": "重新整理中…",
|
||||
@@ -1170,6 +1225,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"rewind.disabledNoBoundary": "這條訊息的會話邊界不可用",
|
||||
"rewind.disabledNoCode": "這條訊息之後沒有捕獲到程式碼變更",
|
||||
"rewind.disabledNoEarlier": "這條訊息之前沒有可總結內容",
|
||||
"rewind.disabledNoLater": "這條訊息之後沒有可總結內容",
|
||||
"rewind.filesChanged": "{count} 個檔案",
|
||||
"rewind.turnFiles": "本輪 {count} 個",
|
||||
"rewind.both": "回滾程式碼和對話",
|
||||
@@ -1190,6 +1246,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"rewind.busyConversation": "正在回滾對話…",
|
||||
"rewind.busyCode": "正在回滾程式碼…",
|
||||
"rewind.busyBoth": "正在回滾程式碼和對話…",
|
||||
"rewind.failed": "回滾失敗",
|
||||
"undoRewind.turns": "已回滾 {n} 輪對話",
|
||||
"undoRewind.filesRestored": "還原了 {n} 個檔案",
|
||||
"undoRewind.filesRemoved": "移除了 {n} 個檔案",
|
||||
@@ -1316,6 +1373,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"mock.topicAi": "AI 工程化最佳實踐",
|
||||
"mock.topicLab": "臨時探索與實驗",
|
||||
"mock.approvalSubject": "npm run build\n\n需要執行建構命令來驗證前端產物和樣式打包是否正常。",
|
||||
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失敗,需要確認是否僅本次不進沙箱重跑。",
|
||||
"mock.sandboxEscapeSubject": "僅本次不進沙箱執行:go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
"mock.askQ1Header": "處理方向",
|
||||
"mock.askQ1Prompt": "git pull 的衝突你想怎麼處理?",
|
||||
"mock.askQ1Opt1Label": "git stash 後 pull",
|
||||
@@ -1487,6 +1547,11 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"status.costLabel": "會話費用",
|
||||
"status.balanceLabel": "餘額",
|
||||
"status.jobsLabel": "任務",
|
||||
"status.tokenModeSwitchBusy": "暫時不能切換省 token 模式。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.tokenModeSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換省 token 模式。",
|
||||
"status.tokenModeSwitchStarting": "會話還在啟動,稍後再切換省 token 模式。",
|
||||
"status.tokenModeSwitchStartupFailed": "目前會話啟動失敗,無法切換省 token 模式:{err}",
|
||||
"status.tokenModeSwitchRetry": "目前會話已變更,請再試一次切換省 token 模式。",
|
||||
"status.tokenModeSwitchFailed": "省 token 模式切換失敗:{err}",
|
||||
"status.modelTitle": "當前模型",
|
||||
"clearContext.title": "清空當前上下文",
|
||||
@@ -1966,6 +2031,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.hooksJsonApplied": "JSON 已校驗,儲存後生效。",
|
||||
"settings.hooksJsonFormatted": "JSON 已格式化,儲存後生效。",
|
||||
"settings.hooksJsonInvalid": "JSON 無效:{error}",
|
||||
"settings.hooksJsonExpectedObjectArray": "應為物件或陣列。",
|
||||
"settings.hooksJsonItemObject": "Hook 項必須是物件。",
|
||||
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
|
||||
"settings.hooksJsonEventItemObject": "Hook {event} 的項目必須是物件。",
|
||||
"settings.hooksJsonClipboardEmpty": "剪貼簿為空",
|
||||
"settings.hooksJsonClipboardUnavailable": "無法存取剪貼簿;可手動複製文字框內容。",
|
||||
"settings.hooksJsonPasteFailed": "貼上失敗:{error}",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"common.busyHint": "请先完成或停止当前回合",
|
||||
"common.loading": "加载中…",
|
||||
"composer.readOnlyChannel": "渠道会话只读",
|
||||
"composer.workspaceStarting": "工作区还在启动",
|
||||
"app.splashSubtitle": "Agent Workspace",
|
||||
|
||||
// 顶栏
|
||||
@@ -532,14 +533,37 @@ export const zh: Record<DictKey, string> = {
|
||||
"modelSwitcher.searchPlaceholder": "搜索模型…",
|
||||
"modelSwitcher.noMatches": "没有匹配的模型",
|
||||
"status.noModels": "没有可切换模型",
|
||||
"status.modelSwitchBusy": "暂时不能切换模型。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.modelSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换模型。",
|
||||
"status.modelSwitchStarting": "会话还在启动,稍后再切换模型。",
|
||||
"status.modelSwitchStartupFailed": "当前会话启动失败,无法切换模型:{err}",
|
||||
"status.modelSwitchRetry": "当前会话已变化,请再试一次切换模型。",
|
||||
"status.modelSwitchUnknown": "未知模型 {model}。",
|
||||
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
|
||||
"status.modelSwitchFailed": "模型切换失败:{err}",
|
||||
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切换到 {fallback}。",
|
||||
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
|
||||
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
|
||||
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
|
||||
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切换失败:{err}",
|
||||
"status.tokenModeSwitchBusy": "暂时不能切换省 token 模式。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.tokenModeSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换省 token 模式。",
|
||||
"status.tokenModeSwitchStarting": "会话还在启动,稍后再切换省 token 模式。",
|
||||
"status.tokenModeSwitchStartupFailed": "当前会话启动失败,无法切换省 token 模式:{err}",
|
||||
"status.tokenModeSwitchRetry": "当前会话已变化,请再试一次切换省 token 模式。",
|
||||
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
|
||||
"status.sessionAutosaveFailed": "会话自动保存失败:{err}",
|
||||
"status.sessionSaveFailedBefore": "在{action}前保存会话失败:{err}",
|
||||
"status.actionChangingModel": "切换模型",
|
||||
"status.actionChangingEffort": "切换推理力度",
|
||||
"status.actionChangingTokenMode": "切换省 token 模式",
|
||||
"status.actionRebuildingSettings": "刷新设置",
|
||||
"status.actionSwitchingSessions": "切换会话",
|
||||
"status.actionSwitchingTabs": "切换标签页",
|
||||
"status.actionAutosave": "自动保存",
|
||||
"status.actionCurrentSession": "保存当前会话",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
"status.effortAutoTitle": "推理力度:auto(模型默认:{def})",
|
||||
@@ -664,6 +688,29 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.allowRulePersistent": "总是允许(保存)",
|
||||
"approval.allowEditsSession": "本会话允许编辑",
|
||||
"approval.allowFilePersistent": "总是允许此文件(保存)",
|
||||
"approval.allowSandboxEscapeSession": "本会话使用真实环境",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "编辑文件",
|
||||
"approval.toolLabelWriteFile": "写入文件",
|
||||
"approval.toolLabelMultiEdit": "批量编辑",
|
||||
"approval.toolLabelMoveFile": "移动文件",
|
||||
"approval.toolLabelWebFetch": "读取网页",
|
||||
"approval.toolLabelRunSkill": "运行技能",
|
||||
"approval.toolLabelRemember": "保存记忆",
|
||||
"approval.toolLabelForget": "删除记忆",
|
||||
"approval.toolLabelSandboxEscape": "bash 沙箱降级审批",
|
||||
"approval.toolLabelPlanModeReadOnly": "计划模式只读命令",
|
||||
"approval.memorySaveUpdate": "保存/更新记忆",
|
||||
"approval.memoryBodyLabel": "正文",
|
||||
"approval.memoryArchivePrefix": "归档记忆 ",
|
||||
"approval.planModeMcpTrustSubject": "将 MCP {target} 作为计划/研究只读工具信任",
|
||||
"approval.planModeMcpTrustReason": "这个 MCP 工具声明自己是只读的,但外部只读提示需要你确认后,计划模式才能使用。选择总是允许可为后续计划和只读研究记住这份信任。",
|
||||
"approval.planModeBashTrustSubject": "在计划模式中信任 {prefix} 为只读命令前缀\n命令:{command}",
|
||||
"approval.planModeBashTrustReason": "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
|
||||
"approval.sandboxEscapeSubjectFallback": "仅本次不进沙箱运行 shell 命令",
|
||||
"approval.sandboxEscapeSubjectPrefix": "仅本次不进沙箱运行:",
|
||||
"approval.sandboxEscapeWrapReason": "Windows 沙箱无法包装这条命令。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
|
||||
// 提问卡片
|
||||
"ask.title": "需要你决定",
|
||||
@@ -733,6 +780,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "无法打开会话",
|
||||
"history.failedOpenGlobal": "无法打开全局会话",
|
||||
"history.missingWorkspaceRoot": "无法打开会话:缺少工作区路径。",
|
||||
"history.selectSession": "选择一个会话以预览。",
|
||||
"history.trashSessionActions": "回收站会话操作",
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
@@ -746,9 +794,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父会话:{parent}",
|
||||
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
|
||||
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
|
||||
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
|
||||
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目",
|
||||
@@ -1544,6 +1593,15 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.modelUsage": "使用模型",
|
||||
"settings.modelUnavailable": "{ref} 不可用。请选择已配置模型,或先添加模型服务接入。",
|
||||
"settings.modelNeedsKey": "{provider} 需要配置 API key 后才能运行该模型。",
|
||||
"settings.errorUnknown": "操作失败。",
|
||||
"settings.errorUnknownModel": "未知模型 {model}。",
|
||||
"settings.errorModelProviderMissing": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
|
||||
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因为提供商 {provider} 尚未配置 API Key。",
|
||||
"settings.errorRemoveAccessBusy": "请先停止正在使用 {provider} 的任务,再移除模型服务接入。",
|
||||
"settings.errorDeleteProviderBusy": "请先停止正在使用 {provider} 的任务,再删除模型服务。",
|
||||
"settings.errorSaveBeforeRemoveAccess": "移除模型服务接入前保存当前会话失败:{err}",
|
||||
"settings.errorSaveBeforeDeleteProvider": "删除模型服务前保存当前会话失败:{err}",
|
||||
"settings.errorRemoveProviderNoFallback": "模型服务 {provider} 正被打开的标签页使用,且没有其他可用模型服务可切换。",
|
||||
"settings.customProviders": "自定义供应商",
|
||||
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
|
||||
@@ -1656,6 +1714,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
|
||||
"settings.providerExtraBodyObjectRequired": "额外请求体必须是 JSON 对象。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
|
||||
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
|
||||
"settings.fetchingModels": "刷新中…",
|
||||
@@ -1738,6 +1798,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.hooksJsonApplied": "JSON 已校验,保存后生效。",
|
||||
"settings.hooksJsonFormatted": "JSON 已格式化,保存后生效。",
|
||||
"settings.hooksJsonInvalid": "JSON 无效:{error}",
|
||||
"settings.hooksJsonExpectedObjectArray": "应为对象或数组。",
|
||||
"settings.hooksJsonItemObject": "Hook 项必须是对象。",
|
||||
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
|
||||
"settings.hooksJsonEventItemObject": "Hook {event} 的项目必须是对象。",
|
||||
"settings.hooksJsonClipboardEmpty": "剪贴板为空",
|
||||
"settings.hooksJsonClipboardUnavailable": "无法访问剪贴板;可手动复制文本框内容。",
|
||||
"settings.hooksJsonPasteFailed": "粘贴失败:{error}",
|
||||
@@ -1827,6 +1891,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
|
||||
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
|
||||
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
|
||||
"rewind.disabledNoLater": "这条消息之后没有可总结内容",
|
||||
"rewind.filesChanged": "{count} 个文件",
|
||||
"rewind.turnFiles": "本轮 {count} 个",
|
||||
"rewind.both": "回滚代码和对话",
|
||||
@@ -1847,6 +1912,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"rewind.busyConversation": "正在回滚对话…",
|
||||
"rewind.busyCode": "正在回滚代码…",
|
||||
"rewind.busyBoth": "正在回滚代码和对话…",
|
||||
"rewind.failed": "回滚失败",
|
||||
"undoRewind.turns": "已回滚 {n} 轮对话",
|
||||
"undoRewind.filesRestored": "还原了 {n} 个文件",
|
||||
"undoRewind.filesRemoved": "移除了 {n} 个文件",
|
||||
@@ -2029,6 +2095,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"mock.topicAi": "AI 工程化最佳实践",
|
||||
"mock.topicLab": "临时探索与实验",
|
||||
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
|
||||
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失败,需要确认是否仅本次不进沙箱重跑。",
|
||||
"mock.sandboxEscapeSubject": "仅本次不进沙箱运行:go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
"mock.askQ1Header": "处理方向",
|
||||
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
|
||||
"mock.askQ1Opt1Label": "git stash 后 pull",
|
||||
|
||||
@@ -5207,11 +5207,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
min-width: 0;
|
||||
}
|
||||
.tool__nested-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 34px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
color: color-mix(in srgb, var(--fg-dim) 86%, var(--fg));
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tool__nested-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: inherit;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tool__status-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
|
||||
+46
-4
@@ -57,6 +57,7 @@ type heartbeatConfig struct {
|
||||
type HeartbeatEngine struct {
|
||||
mu sync.Mutex
|
||||
tasks []HeartbeatTask
|
||||
cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe)
|
||||
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
|
||||
done chan struct{}
|
||||
running bool
|
||||
@@ -99,6 +100,28 @@ func (e *HeartbeatEngine) loadTasks() []HeartbeatTask {
|
||||
return cfg.Tasks
|
||||
}
|
||||
|
||||
// noteConfigModLocked records the config file's current mtime so the tick
|
||||
// external-edit probe does not re-read a file the engine itself just wrote.
|
||||
func (e *HeartbeatEngine) noteConfigModLocked() {
|
||||
if info, err := os.Stat(e.configPath()); err == nil {
|
||||
e.cfgMod = info.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
// adoptExternalEditsLocked re-reads the config when its mtime moved since the
|
||||
// engine last touched the file: heartbeat-tasks.json is documented as human-
|
||||
// and AI-editable, so an external edit should be scheduled by the next tick
|
||||
// rather than waiting for a manual Refresh or an app restart.
|
||||
func (e *HeartbeatEngine) adoptExternalEditsLocked() {
|
||||
info, err := os.Stat(e.configPath())
|
||||
if err != nil || info.ModTime().Equal(e.cfgMod) {
|
||||
return
|
||||
}
|
||||
e.cfgMod = info.ModTime()
|
||||
e.tasks = e.loadTasks()
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
}
|
||||
|
||||
// saveTasks writes tasks to disk atomically.
|
||||
func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error {
|
||||
if tasks == nil {
|
||||
@@ -129,6 +152,7 @@ func (e *HeartbeatEngine) Start() {
|
||||
return
|
||||
}
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
e.running = true
|
||||
go e.loop()
|
||||
log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks))
|
||||
@@ -160,10 +184,12 @@ func (e *HeartbeatEngine) loop() {
|
||||
}
|
||||
|
||||
// tick checks every enabled task and runs those whose interval has elapsed.
|
||||
// It merges results (topicId, lastRunAt) rather than replacing the full list,
|
||||
// so concurrent HeartbeatSaveTasks edits are not lost.
|
||||
// It first adopts any external edit to the config file (human/AI-editable),
|
||||
// then merges results (topicId, lastRunAt) rather than replacing the full
|
||||
// list, so concurrent HeartbeatSaveTasks edits are not lost.
|
||||
func (e *HeartbeatEngine) tick() {
|
||||
e.mu.Lock()
|
||||
e.adoptExternalEditsLocked()
|
||||
tasks := append([]HeartbeatTask(nil), e.tasks...)
|
||||
e.mu.Unlock()
|
||||
|
||||
@@ -374,6 +400,7 @@ func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
out := make([]HeartbeatTask, len(e.tasks))
|
||||
copy(out, e.tasks)
|
||||
@@ -386,7 +413,9 @@ func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error {
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
return e.saveTasks(tasks)
|
||||
err := e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) {
|
||||
@@ -431,7 +460,18 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
tasks := append([]HeartbeatTask(nil), e.tasks...)
|
||||
// Rebase onto the on-disk list before the full-list save: the config file
|
||||
// is documented as human- and AI-editable, so an external edit may have
|
||||
// landed after the in-memory snapshot this tick ran from. The engine owns
|
||||
// only the run-state fields (TopicID, LastRunAt, CreatedAt backfill);
|
||||
// task definitions added, edited, or deleted externally are adopted from
|
||||
// disk, so the save below can never silently roll an external edit back.
|
||||
tasks := e.loadTasks()
|
||||
if tasks == nil {
|
||||
// Missing or unreadable file: fall back to the in-memory list so the
|
||||
// run state still lands (the historical behavior).
|
||||
tasks = append([]HeartbeatTask(nil), e.tasks...)
|
||||
}
|
||||
for i := range tasks {
|
||||
update, ok := updates[tasks[i].ID]
|
||||
if !ok {
|
||||
@@ -448,7 +488,9 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
}
|
||||
}
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
_ = e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
}
|
||||
|
||||
// parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -362,3 +363,73 @@ func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) {
|
||||
t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{
|
||||
tasks: []HeartbeatTask{
|
||||
{ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true},
|
||||
},
|
||||
}
|
||||
// An external editor (the documented human/AI flow) rewrote the file after
|
||||
// the engine's in-memory snapshot: task a was edited and task b was added.
|
||||
external := []HeartbeatTask{
|
||||
{ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true},
|
||||
{ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false},
|
||||
}
|
||||
if err := engine.saveTasks(external); err != nil {
|
||||
t.Fatalf("seed external file: %v", err)
|
||||
}
|
||||
|
||||
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
|
||||
"a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242},
|
||||
})
|
||||
|
||||
if len(engine.tasks) != 2 {
|
||||
t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks)
|
||||
}
|
||||
got := engine.tasks[0]
|
||||
if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" {
|
||||
t.Fatalf("external edit was rolled back by the run-state save: %+v", got)
|
||||
}
|
||||
if got.TopicID != "topic-a" || got.LastRunAt != 4242 {
|
||||
t.Fatalf("run state was not merged onto the disk copy: %+v", got)
|
||||
}
|
||||
// The full-list save must have preserved the externally added task on disk.
|
||||
onDisk := engine.loadTasks()
|
||||
if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" {
|
||||
t.Fatalf("externally added task was lost on save: %+v", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := newHeartbeatEngine(nil)
|
||||
if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil {
|
||||
t.Fatalf("seed file: %v", err)
|
||||
}
|
||||
engine.mu.Lock()
|
||||
engine.tasks = engine.loadTasks()
|
||||
engine.noteConfigModLocked()
|
||||
engine.mu.Unlock()
|
||||
|
||||
// External edit lands after the engine last touched the file. Force the
|
||||
// mtime forward so coarse filesystem timestamps cannot make this flaky.
|
||||
if err := engine.saveTasks([]HeartbeatTask{
|
||||
{ID: "a", Title: "A", Interval: "1h", Enabled: false},
|
||||
{ID: "b", Title: "added externally", Interval: "1h", Enabled: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("external edit: %v", err)
|
||||
}
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(engine.configPath(), future, future); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
|
||||
engine.tick() // disabled tasks only: adoption runs, nothing executes
|
||||
|
||||
tasks := engine.ListTasks()
|
||||
if len(tasks) != 2 || tasks[1].ID != "b" {
|
||||
t.Fatalf("tick did not adopt the external edit: %+v", tasks)
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,21 @@ import (
|
||||
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
"reasonix/internal/sandbox"
|
||||
_ "reasonix/internal/tool/builtin"
|
||||
)
|
||||
|
||||
// runWindowsSandboxHelperIfRequested reports whether argv (os.Args-shaped, so
|
||||
// argv[0] is the program name) asks this process to act as the hidden Windows
|
||||
// sandbox helper, and runs it when so. Split from main so tests can pin that
|
||||
// the desktop binary keeps the helper route the sandbox wrapper depends on.
|
||||
func runWindowsSandboxHelperIfRequested(argv []string) (int, bool) {
|
||||
if len(argv) > 1 && argv[1] == sandbox.WindowsHelperCommand {
|
||||
return sandbox.RunWindowsSandboxHelper(argv[2:], os.Stdin, os.Stdout, os.Stderr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// assets embeds the built frontend. `all:` so dotfiles (e.g. the dist .gitkeep
|
||||
// that keeps this directive compilable before the first `pnpm build`) are
|
||||
// included. A real run requires `pnpm build` (or `wails build`) to populate dist.
|
||||
@@ -90,6 +102,16 @@ func linuxWebviewGpuPolicy(pattern string) linux.WebviewGpuPolicy {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// The Windows bash sandbox relaunches the current executable as a hidden
|
||||
// helper process. Dispatch it before any Wails or single-instance setup:
|
||||
// otherwise every sandboxed command starts a second GUI instance that
|
||||
// forwards to the running app and exits 0 with no output, so bash silently
|
||||
// returns empty on Windows (#6051, #6067, #6072).
|
||||
if code, ok := runWindowsSandboxHelperIfRequested(os.Args); ok {
|
||||
os.Exit(code)
|
||||
}
|
||||
sandbox.RegisterHelperDispatch()
|
||||
|
||||
app := NewApp()
|
||||
|
||||
// Restore saved window size, or fall back to the default.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// recoveryGCInterval bounds how often the background sweep repeats after the
|
||||
// startup run. Recovery branches accumulate slowly (only on a save conflict),
|
||||
// so a low frequency is plenty and keeps the disk scan off the hot path.
|
||||
const recoveryGCInterval = 6 * time.Hour
|
||||
|
||||
// startRecoveryGC waits for tab restore to complete, runs one sweep, then
|
||||
// repeats on an interval until the app context is cancelled. The wait is
|
||||
// load-bearing: restoreOrBuildTabs populates a.tabs asynchronously, and a
|
||||
// sweep against the pre-restore empty tab map would see every saved tab's
|
||||
// session as closed — and DeleteSession's tab-list persistence would then
|
||||
// overwrite desktop-tabs.json with that empty snapshot.
|
||||
func (a *App) startRecoveryGC() {
|
||||
a.goSafe("recoveryGC", func() {
|
||||
select {
|
||||
case <-a.tabsRestoredSignal():
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
a.sweepReclaimableRecoveryBranches()
|
||||
ticker := time.NewTicker(recoveryGCInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.sweepReclaimableRecoveryBranches()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
|
||||
// preserve nothing unique (their content is covered by a still-present parent
|
||||
// session), were never continued on, sat idle past the grace period, and are
|
||||
// not held by any runtime. Trashing — never hard deletion — keeps every swept
|
||||
// branch recoverable from the session trash. Returns how many were reclaimed.
|
||||
func (a *App) sweepReclaimableRecoveryBranches() int {
|
||||
return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now())
|
||||
}
|
||||
|
||||
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time) int {
|
||||
reclaimed := 0
|
||||
for _, dir := range dirs {
|
||||
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, agent.RecoveryGCGracePeriod)
|
||||
if err != nil {
|
||||
slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, path := range reclaimable {
|
||||
// Re-check liveness right before disposal: the scan is a snapshot,
|
||||
// and the user may have opened the branch since. DeleteSession then
|
||||
// runs the full removal path (removal guard, runtime unbinding,
|
||||
// trash move), so even a miss here lands in the recoverable trash.
|
||||
if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
|
||||
continue
|
||||
}
|
||||
if err := a.DeleteSession(path); err != nil {
|
||||
slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err)
|
||||
continue
|
||||
}
|
||||
reclaimed++
|
||||
}
|
||||
}
|
||||
if reclaimed > 0 {
|
||||
slog.Info("desktop: moved redundant recovery branches to the session trash", "count", reclaimed)
|
||||
a.emitProjectTreeChanged()
|
||||
}
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
// recoveryGCDirs returns every session directory the desktop lists sessions
|
||||
// from: the global desktop and legacy shared dirs plus each saved project's
|
||||
// session dirs, deduplicated.
|
||||
func recoveryGCDirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
add := func(dir string) {
|
||||
if dir == "" || seen[dir] {
|
||||
return
|
||||
}
|
||||
seen[dir] = true
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
add(desktopSessionDir(globalWorkspaceRoot()))
|
||||
add(config.SessionDir())
|
||||
for _, project := range loadProjectsFile().Projects {
|
||||
if root := normalizeProjectRoot(project.Root); root != "" {
|
||||
add(desktopSessionDir(root))
|
||||
add(config.ProjectSessionDir(root))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// sessionOpenInAnyTab reports whether any tab's current session is path.
|
||||
// Lease checks cover live runtimes; this additionally covers tabs that hold a
|
||||
// session without a lease (read-only channel views).
|
||||
func (a *App) sessionOpenInAnyTab(path string) bool {
|
||||
key := sessionRuntimeKey(path)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tab := range a.tabs {
|
||||
if tab == nil {
|
||||
continue
|
||||
}
|
||||
if sessionRuntimeKey(tab.currentSessionPath()) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// forkCoveredRecoveryBranch builds the reclaimable shape in dir: a conflict
|
||||
// fork whose parent went on to contain everything the fork preserved.
|
||||
func forkCoveredRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string) {
|
||||
t.Helper()
|
||||
parentPath = filepath.Join(dir, name+".jsonl")
|
||||
disk := agent.NewSession("sys")
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
|
||||
if err := disk.Save(parentPath); err != nil {
|
||||
t.Fatalf("Save parent: %v", err)
|
||||
}
|
||||
stale := agent.NewSession("sys")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
|
||||
info, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
covering := agent.NewSession("")
|
||||
covering.Messages = append([]provider.Message(nil), stale.Snapshot()...)
|
||||
covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
|
||||
if err := covering.Save(parentPath); err != nil {
|
||||
t.Fatalf("Save covering parent: %v", err)
|
||||
}
|
||||
return parentPath, info.Path
|
||||
}
|
||||
|
||||
func TestRecoveryGCTrashesCoveredForkAndKeepsParent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
parentPath, branchPath := forkCoveredRecoveryBranch(t, dir, "session")
|
||||
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
|
||||
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 1 {
|
||||
t.Fatalf("reclaimed = %d, want 1", got)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("reclaimed branch still present at %s (err=%v)", branchPath, err)
|
||||
}
|
||||
key := filepath.Base(branchPath)
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, key, key)
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("reclaimed branch should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(parentPath); err != nil {
|
||||
t.Fatalf("parent session must be untouched: %v", err)
|
||||
}
|
||||
|
||||
// A second sweep is a no-op: nothing left to reclaim.
|
||||
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
|
||||
t.Fatalf("second sweep reclaimed = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryGCSkipsBranchOpenInTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
_, branchPath := forkCoveredRecoveryBranch(t, dir, "open")
|
||||
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
|
||||
t.Fatalf("reclaimed = %d, want 0 while the branch is open in a tab", got)
|
||||
}
|
||||
if _, err := os.Stat(branchPath); err != nil {
|
||||
t.Fatalf("open branch must be untouched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryGCFirstSweepWaitsForTabRestore forces the startup race the
|
||||
// review caught: a saved recovery tab exists in desktop-tabs.json but a.tabs
|
||||
// has not been populated yet. The GC's first sweep must wait for the restore
|
||||
// gate — sweeping early would judge the branch "not open in any tab" and
|
||||
// DeleteSession would persist the pre-restore (empty) tab list over the
|
||||
// user's saved one.
|
||||
func TestRecoveryGCFirstSweepWaitsForTabRestore(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
_, branchPath := forkCoveredRecoveryBranch(t, dir, "startup")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
app := &App{
|
||||
ctx: ctx,
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
tabsRestored: make(chan struct{}),
|
||||
}
|
||||
|
||||
swept := make(chan int, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-app.tabsRestoredSignal():
|
||||
case <-ctx.Done():
|
||||
swept <- -1
|
||||
return
|
||||
}
|
||||
swept <- app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
|
||||
}()
|
||||
|
||||
// Gate still closed: the sweep must not have run — the branch is intact.
|
||||
select {
|
||||
case n := <-swept:
|
||||
t.Fatalf("sweep ran before tab restore completed (reclaimed=%d)", n)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if _, err := os.Stat(branchPath); err != nil {
|
||||
t.Fatalf("branch touched before restore completed: %v", err)
|
||||
}
|
||||
|
||||
// Restore lands the saved tab holding the branch, then opens the gate:
|
||||
// the sweep runs and must skip the now-open branch.
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
|
||||
app.mu.Lock()
|
||||
app.tabs["tab"] = tab
|
||||
app.mu.Unlock()
|
||||
app.markTabsRestored()
|
||||
|
||||
if n := <-swept; n != 0 {
|
||||
t.Fatalf("post-restore sweep reclaimed = %d, want 0 (branch is open in a restored tab)", n)
|
||||
}
|
||||
if _, err := os.Stat(branchPath); err != nil {
|
||||
t.Fatalf("restored tab's branch must be untouched: %v", err)
|
||||
}
|
||||
|
||||
// markTabsRestored is idempotent (restore + recover paths may both fire).
|
||||
app.markTabsRestored()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Sanitized errors surfaced when a destructive or restore session operation
|
||||
// hits a real filesystem blocker. Like errSessionBusyElsewhere, they
|
||||
// intentionally carry no path, so raw OS error text never reaches the UI.
|
||||
var (
|
||||
errSessionFileLocked = errors.New("a session file is temporarily locked by another program (often antivirus or sync tools) — wait a moment and retry")
|
||||
errSessionFileAccessDenied = errors.New("access to a session file was denied — close programs that may be using it or check folder permissions, then retry")
|
||||
errSessionDiskFull = errors.New("not enough disk space to finish the operation — free some space and retry")
|
||||
)
|
||||
|
||||
// friendlySessionFileError rewrites raw OS-level filesystem errors from the
|
||||
// session trash/restore/purge flows (e.g. a Windows sharing violation while a
|
||||
// scanner holds a transcript) into the actionable, path-free errors above.
|
||||
// The original error is logged so diagnostics keep the path and errno.
|
||||
// Unrecognized errors — including already-sanitized ones like
|
||||
// errSessionBusyElsewhere — pass through unchanged.
|
||||
func friendlySessionFileError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return err
|
||||
}
|
||||
var friendly error
|
||||
switch {
|
||||
case isFileInUseErrno(errno):
|
||||
friendly = errSessionFileLocked
|
||||
case isAccessDeniedErrno(errno):
|
||||
friendly = errSessionFileAccessDenied
|
||||
case isDiskFullErrno(errno):
|
||||
friendly = errSessionDiskFull
|
||||
default:
|
||||
return err
|
||||
}
|
||||
slog.Warn("desktop: session file operation blocked", "err", err)
|
||||
return friendly
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Platform errnos for the blocked-file classes. Windows values follow the
|
||||
// isRenameCrossDeviceOrBusy convention of naming the raw code inline.
|
||||
func blockedFileErrnos(t *testing.T) (inUse, accessDenied, diskFull syscall.Errno) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
return 32, 5, 112 // ERROR_SHARING_VIOLATION, ERROR_ACCESS_DENIED, ERROR_DISK_FULL
|
||||
}
|
||||
return syscall.EBUSY, syscall.EACCES, syscall.ENOSPC
|
||||
}
|
||||
|
||||
func TestFriendlySessionFileErrorMapsBlockedFileErrors(t *testing.T) {
|
||||
inUse, accessDenied, diskFull := blockedFileErrnos(t)
|
||||
secret := "/very/secret/session.jsonl"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "rename sharing violation",
|
||||
err: &os.LinkError{Op: "rename", Old: secret, New: secret + ".trash", Err: inUse},
|
||||
want: errSessionFileLocked,
|
||||
},
|
||||
{
|
||||
name: "remove access denied",
|
||||
err: &os.PathError{Op: "remove", Path: secret, Err: accessDenied},
|
||||
want: errSessionFileAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "write disk full",
|
||||
err: &os.PathError{Op: "write", Path: secret, Err: diskFull},
|
||||
want: errSessionDiskFull,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := friendlySessionFileError(tc.err)
|
||||
if got != tc.want {
|
||||
t.Fatalf("friendlySessionFileError() = %v, want %v", got, tc.want)
|
||||
}
|
||||
if strings.Contains(got.Error(), secret) {
|
||||
t.Fatalf("sanitized error leaks the path: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendlySessionFileErrorPassesThroughOtherErrors(t *testing.T) {
|
||||
if err := friendlySessionFileError(nil); err != nil {
|
||||
t.Fatalf("nil should stay nil, got %v", err)
|
||||
}
|
||||
plain := errors.New("plain failure")
|
||||
if got := friendlySessionFileError(plain); got != plain {
|
||||
t.Fatalf("plain error rewritten to %v", got)
|
||||
}
|
||||
if got := friendlySessionFileError(errSessionBusyElsewhere); got != errSessionBusyElsewhere {
|
||||
t.Fatalf("sanitized busy error rewritten to %v", got)
|
||||
}
|
||||
notExist := &os.PathError{Op: "lstat", Path: "gone.jsonl", Err: syscall.ENOENT}
|
||||
if got := friendlySessionFileError(notExist); got != error(notExist) {
|
||||
t.Fatalf("not-exist error rewritten to %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
func isFileInUseErrno(errno syscall.Errno) bool {
|
||||
return errno == syscall.EBUSY || errno == syscall.ETXTBSY
|
||||
}
|
||||
|
||||
func isAccessDeniedErrno(errno syscall.Errno) bool {
|
||||
return errno == syscall.EACCES || errno == syscall.EPERM
|
||||
}
|
||||
|
||||
func isDiskFullErrno(errno syscall.Errno) bool {
|
||||
return errno == syscall.ENOSPC || errno == syscall.EDQUOT
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func isFileInUseErrno(errno syscall.Errno) bool {
|
||||
return errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_LOCK_VIOLATION
|
||||
}
|
||||
|
||||
func isAccessDeniedErrno(errno syscall.Errno) bool {
|
||||
return errno == windows.ERROR_ACCESS_DENIED
|
||||
}
|
||||
|
||||
func isDiskFullErrno(errno syscall.Errno) bool {
|
||||
return errno == windows.ERROR_DISK_FULL || errno == windows.ERROR_HANDLE_DISK_FULL
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
// The lease registry folds session paths through agent.CanonicalSessionPath
|
||||
// (lowercased on Windows). Every desktop "same session" comparison must fold
|
||||
// the same way, or a lease the tab itself holds looks foreign and every
|
||||
// model/effort/token switch self-locks with the "already open in another
|
||||
// window" error (#5999, #6006, #5996).
|
||||
|
||||
func TestSessionRuntimeKeyMatchesLeaseKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Mixed-case segment on every platform; on Windows the lease key folds it.
|
||||
path := filepath.Join(dir, "Sessions-Dir", "20260705-Test.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire lease: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
if got, want := sessionRuntimeKey(lease.Path()), sessionRuntimeKey(path); got != want {
|
||||
t.Fatalf("lease path key %q != session path key %q; lease reuse checks will self-lock", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRuntimeKeyIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "Case-Mix", "20260705-Test.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
if key == "" {
|
||||
t.Fatal("empty key for non-empty path")
|
||||
}
|
||||
if again := sessionRuntimeKey(key); again != key {
|
||||
t.Fatalf("sessionRuntimeKey not idempotent: %q -> %q", key, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRuntimeKeyFoldsCaseOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive path identity is Windows-only")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "Sessions", "20260705-Test.jsonl")
|
||||
upper := strings.ToUpper(path)
|
||||
if sessionRuntimeKey(path) != sessionRuntimeKey(upper) {
|
||||
t.Fatalf("case variants of one file produced distinct keys: %q vs %q",
|
||||
sessionRuntimeKey(path), sessionRuntimeKey(upper))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalTabSessionPathNormalizesOutsideSessionDir(t *testing.T) {
|
||||
// Project-scope sessions live outside config.SessionDir(); the fallback
|
||||
// must still clean the shape so one file cannot split into two keys.
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "projects", "p1", "sessions", "20260705-a.jsonl")
|
||||
messy := filepath.Join(dir, "projects", "p1", ".", "sessions") + string(filepath.Separator) + "20260705-a.jsonl"
|
||||
if sessionRuntimeKey(base) != sessionRuntimeKey(messy) {
|
||||
t.Fatalf("path shape variants split keys: %q vs %q",
|
||||
sessionRuntimeKey(base), sessionRuntimeKey(messy))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionLeaseReusesHeldLease(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "Sessions", "20260705-Reuse.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
tab := &WorkspaceTab{ID: "tab_reuse"}
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("first ensure: %v", err)
|
||||
}
|
||||
defer tab.releaseSessionLease()
|
||||
|
||||
acquires := 0
|
||||
sessionLeaseAcquireHookForTest = func() { acquires++ }
|
||||
defer func() { sessionLeaseAcquireHookForTest = nil }()
|
||||
|
||||
// Same path again — and the lease's own (canonical) form: both must hit
|
||||
// the fast path instead of re-acquiring against our own registry entry.
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("re-ensure same path: %v", err)
|
||||
}
|
||||
if err := tab.ensureSessionLease(tab.sessionLeaseRuntimeKey()); err != nil {
|
||||
t.Fatalf("re-ensure canonical form: %v", err)
|
||||
}
|
||||
if acquires != 0 {
|
||||
t.Fatalf("expected fast-path reuse, got %d new acquires", acquires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReclaimAllowsMissingLeaseInfo(t *testing.T) {
|
||||
// lease.json deleted or torn: Info is nil but nothing actually holds the
|
||||
// session. The reclaim attempt must be allowed — the OS lock arbitrates —
|
||||
// instead of wedging the session as busy on missing metadata (#5999's
|
||||
// fresh-install variant).
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "tab_nil_info"}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
path := filepath.Join(t.TempDir(), "Sessions", "20260705-a.jsonl")
|
||||
|
||||
errNil := &agent.SessionLeaseError{Path: path}
|
||||
if !app.canReclaimCurrentProcessSessionLease(tab, path, errNil) {
|
||||
t.Fatal("nil-Info lease error must allow a reclaim attempt")
|
||||
}
|
||||
|
||||
foreign := &agent.SessionLeaseError{Path: path, Info: &agent.SessionLeaseInfo{
|
||||
SessionPath: path, WriterID: "other-host-1-deadbeef", PID: os.Getpid() + 1,
|
||||
}}
|
||||
if app.canReclaimCurrentProcessSessionLease(tab, path, foreign) {
|
||||
t.Fatal("foreign-holder lease error must not allow reclaim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLeaseBusyErrorOmitsSettingClause(t *testing.T) {
|
||||
generic := (&sessionLeaseBusyError{}).Error()
|
||||
if strings.Contains(generic, "before changing") {
|
||||
t.Fatalf("empty-setting busy error still names a setting: %q", generic)
|
||||
}
|
||||
withSetting := (&sessionLeaseBusyError{setting: "model"}).Error()
|
||||
if !strings.Contains(withSetting, "before changing model") {
|
||||
t.Fatalf("setting busy error lost its clause: %q", withSetting)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
@@ -16,6 +17,21 @@ func systemPromptFrom(messages []provider.Message) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// logSystemPromptSwap leaves a trace whenever a resume/rebind replaces a
|
||||
// conversation's persisted system prompt with different bytes: that swap
|
||||
// invalidates the whole conversation's provider prefix cache (misses bill at
|
||||
// 10x hits) and persists the rewrite. With probe snapshots keeping composition
|
||||
// deterministic, this should fire only on genuine config changes — if it shows
|
||||
// up in field logs without one, a new nondeterminism source crept into the
|
||||
// prompt assembly.
|
||||
func logSystemPromptSwap(persisted, fresh, path string) {
|
||||
if persisted == "" || fresh == "" || persisted == fresh {
|
||||
return
|
||||
}
|
||||
slog.Warn("desktop: resume swapped a differing system prompt; conversation prefix cache will miss",
|
||||
"path", path, "persisted_len", len(persisted), "fresh_len", len(fresh))
|
||||
}
|
||||
|
||||
func withFreshSystemPrompt(messages []provider.Message, system string) []provider.Message {
|
||||
if strings.TrimSpace(system) == "" {
|
||||
return messages
|
||||
@@ -40,9 +56,11 @@ func sessionWithFreshSystemPrompt(session *agent.Session, system string) *agent.
|
||||
return nil
|
||||
}
|
||||
messages := session.Snapshot()
|
||||
if systemPromptFrom(messages) == "" {
|
||||
persisted := systemPromptFrom(messages)
|
||||
if persisted == "" {
|
||||
return session
|
||||
}
|
||||
logSystemPromptSwap(persisted, system, "")
|
||||
return session.CloneWithMessages(withFreshSystemPrompt(messages, system))
|
||||
}
|
||||
|
||||
@@ -55,7 +73,9 @@ func resumeWithFreshSystemPrompt(ctrl interface {
|
||||
return
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
next := withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))
|
||||
fresh := systemPromptFrom(ctrl.History())
|
||||
logSystemPromptSwap(systemPromptFrom(messages), fresh, path)
|
||||
next := withFreshSystemPrompt(messages, fresh)
|
||||
if path != "" {
|
||||
if loaded, err := agent.LoadSession(path); err == nil && loaded != nil {
|
||||
if resumed, ok := loaded.CloneWithMessagesIfCompatible(next); ok {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// capturingProvider records the exact message list of every request it
|
||||
// receives, marshaled at capture time, so tests can compare request bytes.
|
||||
type capturingProvider struct {
|
||||
mu sync.Mutex
|
||||
requests [][]byte
|
||||
}
|
||||
|
||||
func (p *capturingProvider) Name() string { return "capturing" }
|
||||
|
||||
func (p *capturingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
b, err := json.Marshal(req.Messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.requests = append(p.requests, b)
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 1)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *capturingProvider) lastRequestMessages(t *testing.T) []provider.Message {
|
||||
t.Helper()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.requests) == 0 {
|
||||
t.Fatal("provider captured no requests")
|
||||
}
|
||||
var msgs []provider.Message
|
||||
if err := json.Unmarshal(p.requests[len(p.requests)-1], &msgs); err != nil {
|
||||
t.Fatalf("unmarshal captured request: %v", err)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func marshalMessages(t *testing.T, msgs []provider.Message) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal messages: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// copySessionFiles clones a saved transcript (checkpoint anchor, event log,
|
||||
// meta sidecar) to an independent path, so a rebind can load the state saved
|
||||
// at that moment while the original controller keeps running and autosaving.
|
||||
func copySessionFiles(t *testing.T, from, to string) {
|
||||
t.Helper()
|
||||
copied := false
|
||||
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
|
||||
b, err := os.ReadFile(from + suffix)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(to+suffix, b, 0o644); err != nil {
|
||||
t.Fatalf("copy session file %s: %v", suffix, err)
|
||||
}
|
||||
copied = true
|
||||
}
|
||||
if !copied {
|
||||
t.Fatalf("no session files found at %s", from)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebindReproducesRequestBytes is the desktop-level byte-stability guard
|
||||
// for the provider prefix cache. It builds the strongest comparison available:
|
||||
// from ONE saved transcript, run the same follow-up turn twice — once on the
|
||||
// original controller (no rebind, the provider-cache-warm baseline) and once
|
||||
// after the desktop rebind path (agent.LoadSession + sessionWithFreshSystemPrompt
|
||||
// + Resume on a freshly built controller, the shape of tabs.go's restore). The
|
||||
// two requests must be byte-identical END TO END — system prompt, prior user
|
||||
// AND assistant turns, and the composed follow-up. Any divergence means a
|
||||
// desktop rebuild cold-starts the conversation's provider cache at 10x miss
|
||||
// pricing (#2945, #5614).
|
||||
func TestRebindReproducesRequestBytes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
const systemPrompt = "SYSPROMPT stable bytes"
|
||||
|
||||
prov := &capturingProvider{}
|
||||
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Runner: exec, Executor: exec, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard})
|
||||
|
||||
if err := ctrl.RunTurn(context.Background(), "first question"); err != nil {
|
||||
t.Fatalf("first turn: %v", err)
|
||||
}
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
// Freeze the after-turn-one transcript on an independent path: the
|
||||
// baseline turn below autosaves onto the original path, and the rebind
|
||||
// must load the state as saved at this moment.
|
||||
rebindPath := filepath.Join(dir, "rebind.jsonl")
|
||||
copySessionFiles(t, path, rebindPath)
|
||||
|
||||
// Baseline: the follow-up turn on the ORIGINAL controller — the exact
|
||||
// request an uninterrupted (cache-warm) session would send.
|
||||
if err := ctrl.RunTurn(context.Background(), "second question"); err != nil {
|
||||
t.Fatalf("baseline second turn: %v", err)
|
||||
}
|
||||
baseline := prov.lastRequestMessages(t)
|
||||
baselineBytes := marshalMessages(t, baseline)
|
||||
if len(baseline) < 4 {
|
||||
t.Fatalf("baseline request has %d messages, want system + first exchange + follow-up", len(baseline))
|
||||
}
|
||||
|
||||
// Rebind from the transcript saved after turn one: a NEW controller
|
||||
// composes its (identical) system prompt, the persisted transcript is
|
||||
// loaded, the fresh prompt is swapped in, and the controller resumes —
|
||||
// then sends the same follow-up.
|
||||
prov2 := &capturingProvider{}
|
||||
exec2 := agent.New(prov2, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
|
||||
ctrl2 := control.New(control.Options{Runner: exec2, Executor: exec2, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: rebindPath, Label: "test", Sink: event.Discard})
|
||||
loaded, err := agent.LoadSession(rebindPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
ctrl2.Resume(sessionWithFreshSystemPrompt(loaded, systemPromptFrom(ctrl2.History())), rebindPath)
|
||||
|
||||
if err := ctrl2.RunTurn(context.Background(), "second question"); err != nil {
|
||||
t.Fatalf("post-rebind second turn: %v", err)
|
||||
}
|
||||
rebound := prov2.lastRequestMessages(t)
|
||||
reboundBytes := marshalMessages(t, rebound)
|
||||
if string(reboundBytes) != string(baselineBytes) {
|
||||
t.Fatalf("rebind changed the request bytes — the provider prefix cache is invalidated:\nbaseline: %s\nrebound: %s", baselineBytes, reboundBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebindWithDriftedPromptBreaksRequestPrefix pins the failure mode the
|
||||
// guard above protects against: when the freshly composed prompt differs from
|
||||
// the one the transcript was recorded with, the swap rewrites the first
|
||||
// message and the request diverges from the no-rebind baseline. If a future
|
||||
// change moves the swap policy to keep the persisted prompt for resumed
|
||||
// conversations, this test should be updated to assert the bytes survive
|
||||
// instead.
|
||||
func TestRebindWithDriftedPromptBreaksRequestPrefix(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
|
||||
prov := &capturingProvider{}
|
||||
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession("SYSPROMPT v1"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Runner: exec, Executor: exec, SystemPrompt: "SYSPROMPT v1", SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard})
|
||||
if err := ctrl.RunTurn(context.Background(), "first question"); err != nil {
|
||||
t.Fatalf("first turn: %v", err)
|
||||
}
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
rebindPath := filepath.Join(dir, "rebind.jsonl")
|
||||
copySessionFiles(t, path, rebindPath)
|
||||
if err := ctrl.RunTurn(context.Background(), "second question"); err != nil {
|
||||
t.Fatalf("baseline second turn: %v", err)
|
||||
}
|
||||
baseline := prov.lastRequestMessages(t)
|
||||
baselineBytes := marshalMessages(t, baseline)
|
||||
|
||||
prov2 := &capturingProvider{}
|
||||
exec2 := agent.New(prov2, tool.NewRegistry(), agent.NewSession("SYSPROMPT v2 drifted"), agent.Options{}, event.Discard)
|
||||
ctrl2 := control.New(control.Options{Runner: exec2, Executor: exec2, SystemPrompt: "SYSPROMPT v2 drifted", SessionDir: dir, SessionPath: rebindPath, Label: "test", Sink: event.Discard})
|
||||
loaded, err := agent.LoadSession(rebindPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
ctrl2.Resume(sessionWithFreshSystemPrompt(loaded, systemPromptFrom(ctrl2.History())), rebindPath)
|
||||
if err := ctrl2.RunTurn(context.Background(), "second question"); err != nil {
|
||||
t.Fatalf("post-rebind turn: %v", err)
|
||||
}
|
||||
rebound := prov2.lastRequestMessages(t)
|
||||
if string(marshalMessages(t, rebound)) == string(baselineBytes) {
|
||||
t.Fatal("drifted prompt unexpectedly reproduced the baseline request — the swap policy changed; update these guards")
|
||||
}
|
||||
if len(rebound) == 0 || rebound[0].Content == baseline[0].Content {
|
||||
t.Fatalf("drift should surface in the leading system message; got %q", rebound[0].Content)
|
||||
}
|
||||
}
|
||||
+147
-28
@@ -147,6 +147,7 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
|
||||
{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
|
||||
{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
|
||||
{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
|
||||
{src: store.SessionConflictLog(sessionPath), name: stem + ".conflicts.jsonl"},
|
||||
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
|
||||
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
|
||||
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
|
||||
@@ -214,7 +215,30 @@ func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
}
|
||||
defer guard.Release()
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
if info, err := os.Stat(itemDir); err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("session trash target is not a directory: %s", key)
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matches {
|
||||
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
|
||||
@@ -259,7 +283,10 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
|
||||
if removable {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("session already exists in trash: %s", key)
|
||||
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
|
||||
return errSessionBusyElsewhere
|
||||
}
|
||||
return nil
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
@@ -270,37 +297,64 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, error) {
|
||||
type preparedSessionTrashTarget struct {
|
||||
shouldMove bool
|
||||
itemDir string
|
||||
allocateUnique bool
|
||||
}
|
||||
|
||||
func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) {
|
||||
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
|
||||
return false, nil
|
||||
return preparedSessionTrashTarget{}, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
return preparedSessionTrashTarget{}, err
|
||||
}
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if info, err := os.Stat(itemDir); err == nil {
|
||||
if !info.IsDir() {
|
||||
return false, fmt.Errorf("session trash target is not a directory: %s", key)
|
||||
return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key)
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return preparedSessionTrashTarget{}, err
|
||||
}
|
||||
if removable {
|
||||
return false, removeDesktopSessionArtifacts(sessionPath)
|
||||
return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath)
|
||||
}
|
||||
return false, fmt.Errorf("session already exists in trash: %s", key)
|
||||
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
|
||||
return preparedSessionTrashTarget{}, errSessionBusyElsewhere
|
||||
}
|
||||
return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return false, err
|
||||
return preparedSessionTrashTarget{}, err
|
||||
}
|
||||
if err := os.RemoveAll(itemDir); err != nil {
|
||||
return false, err
|
||||
return preparedSessionTrashTarget{}, err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
return preparedSessionTrashTarget{}, err
|
||||
}
|
||||
return true, nil
|
||||
return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil
|
||||
}
|
||||
|
||||
func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) {
|
||||
root := sessionTrashPath(dir)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stem := strings.TrimSuffix(key, ".jsonl")
|
||||
for i := 0; i < 100; i++ {
|
||||
name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i)
|
||||
itemDir := filepath.Join(root, name)
|
||||
if err := os.Mkdir(itemDir, 0o755); err == nil {
|
||||
return itemDir, nil
|
||||
} else if !os.IsExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not allocate unique trash target for session: %s", key)
|
||||
}
|
||||
|
||||
// liveSessionRemovableWithExistingTrash reports whether a live session file may
|
||||
@@ -383,11 +437,11 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
|
||||
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
|
||||
return err
|
||||
}
|
||||
shouldMove, err := prepareSessionTrashTarget(dir, sessionPath, key)
|
||||
target, err := prepareSessionTrashTarget(dir, sessionPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !shouldMove {
|
||||
if !target.shouldMove {
|
||||
return nil
|
||||
}
|
||||
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
|
||||
@@ -397,8 +451,13 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
|
||||
return err
|
||||
}
|
||||
defer guard.Release()
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
itemDir := target.itemDir
|
||||
if target.allocateUnique {
|
||||
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if beforeMove != nil {
|
||||
@@ -443,17 +502,27 @@ func listTrashedSessionFiles(dir string) ([]string, error) {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
key := e.Name()
|
||||
if filepath.Ext(key) != ".jsonl" || filepath.Base(key) != key {
|
||||
continue
|
||||
itemDir := filepath.Join(root, e.Name())
|
||||
keys := []string{}
|
||||
if b, err := os.ReadFile(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil {
|
||||
var meta trashedSessionMeta
|
||||
if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) {
|
||||
keys = append(keys, meta.Key)
|
||||
}
|
||||
}
|
||||
path := filepath.Join(root, key, key)
|
||||
validPath, _, _, err := validateTrashedSessionPath(dir, path)
|
||||
if err != nil {
|
||||
continue
|
||||
if store.IsSessionTranscriptName(e.Name()) {
|
||||
keys = append(keys, e.Name())
|
||||
}
|
||||
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
|
||||
paths = append(paths, validPath)
|
||||
for _, key := range keys {
|
||||
path := filepath.Join(itemDir, key)
|
||||
validPath, _, _, err := validateTrashedSessionPath(dir, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
|
||||
paths = append(paths, validPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
@@ -544,6 +613,8 @@ func movePathIfExists(src, dst string) error {
|
||||
// Try os.Rename first — it's atomic and fast when it works.
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
} else if sourcePathMissing(src) {
|
||||
return nil
|
||||
} else if !isRenameCrossDeviceOrBusy(err) {
|
||||
return err
|
||||
}
|
||||
@@ -572,10 +643,28 @@ func isRenameCrossDeviceOrBusy(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func sourcePathMissing(src string) bool {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return true
|
||||
}
|
||||
_, err := os.Lstat(src)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// copyPathFn is a seam for tests to simulate a source vanishing mid-copy.
|
||||
var copyPathFn = copyPath
|
||||
|
||||
// copyAndRemove recursively copies src to dst, then removes src. Used as a
|
||||
// fallback when os.Rename fails (cross-device or Windows file-lock races).
|
||||
func copyAndRemove(src, dst string) error {
|
||||
if err := copyPath(src, dst); err != nil {
|
||||
if err := copyPathFn(src, dst); err != nil {
|
||||
if sourcePathMissing(src) {
|
||||
// The source vanished mid-copy; drop the partial destination so
|
||||
// the trash never keeps a truncated artifact that a later restore
|
||||
// would resurrect as a corrupted transcript.
|
||||
_ = os.RemoveAll(dst)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// On Windows, wait briefly for any file handle release.
|
||||
@@ -586,6 +675,9 @@ func copyAndRemove(src, dst string) error {
|
||||
func copyPath(src, dst string) error {
|
||||
info, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
mode := info.Mode()
|
||||
@@ -607,6 +699,10 @@ func copyDir(src, dst string, mode os.FileMode) error {
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
_ = os.RemoveAll(dst)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
@@ -623,6 +719,9 @@ func copyFile(src, dst string, mode os.FileMode) error {
|
||||
// Open source file.
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Create destination file.
|
||||
@@ -648,6 +747,9 @@ func copyFile(src, dst string, mode os.FileMode) error {
|
||||
func copySymlink(src, dst string) error {
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return os.Symlink(target, dst)
|
||||
@@ -786,9 +888,19 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
|
||||
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) != 2 || parts[0] != parts[1] {
|
||||
if len(parts) != 2 {
|
||||
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
||||
}
|
||||
if parts[0] != parts[1] {
|
||||
b, err := os.ReadFile(filepath.Join(root, parts[0], sessionTrashMetaFile))
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
||||
}
|
||||
var meta trashedSessionMeta
|
||||
if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] {
|
||||
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
||||
}
|
||||
}
|
||||
if info, err := os.Lstat(absPath); err == nil {
|
||||
if info.IsDir() {
|
||||
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
@@ -1005,6 +1117,13 @@ func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{})
|
||||
if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
if paths, err := listTrashedSessionFiles(dir); err == nil {
|
||||
for _, path := range paths {
|
||||
if filepath.Base(path) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestDeleteSessionKeepsDuplicateLiveSessionHeldByOtherRuntime(t *testing.T)
|
||||
}
|
||||
|
||||
err := app.DeleteSession(filepath.Base(path))
|
||||
if err == nil || !strings.Contains(err.Error(), "session already exists in trash") {
|
||||
if err == nil || !strings.Contains(err.Error(), errSessionBusyElsewhere.Error()) {
|
||||
t.Fatalf("DeleteSession err = %v, want refusal while a foreign runtime holds the lease", err)
|
||||
}
|
||||
if got, readErr := os.ReadFile(path); readErr != nil || string(got) != string(content) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -169,6 +170,8 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
|
||||
eventIndexPath := store.SessionEventIndex(sessionPath)
|
||||
os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
|
||||
conflictLogPath := store.SessionConflictLog(sessionPath)
|
||||
os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644)
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
|
||||
lockPath := store.SessionLockFile(sessionPath)
|
||||
@@ -204,6 +207,7 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
|
||||
trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
|
||||
trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
|
||||
trashConflictLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.conflicts.jsonl")
|
||||
trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
|
||||
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
|
||||
trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
|
||||
@@ -224,6 +228,9 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
|
||||
t.Error("session event index should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(conflictLogPath); !os.IsNotExist(err) {
|
||||
t.Error("session conflict log should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
|
||||
t.Error("session telemetry should be removed from active sessions")
|
||||
}
|
||||
@@ -257,6 +264,9 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(trashEventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashConflictLogPath); err != nil {
|
||||
t.Fatalf("session conflict log should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashTelemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be in trash: %v", err)
|
||||
}
|
||||
@@ -492,6 +502,10 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conflictLogPath := store.SessionConflictLog(sessionPath)
|
||||
if err := os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -537,6 +551,9 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(eventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(conflictLogPath); err != nil {
|
||||
t.Fatalf("session conflict log should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be restored: %v", err)
|
||||
}
|
||||
@@ -579,6 +596,51 @@ func TestRestoreTrashedSessionFileWithEmptyLiveStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashedSessionFileFromUniqueTrashItem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "restore-unique.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
||||
t.Fatalf("trash old: %v", err)
|
||||
}
|
||||
fixedTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"new"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write new live session: %v", err)
|
||||
}
|
||||
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
||||
t.Fatalf("trash new with fixed trash conflict: %v", err)
|
||||
}
|
||||
|
||||
trashed, err := listTrashedSessionFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("list trash: %v", err)
|
||||
}
|
||||
var uniqueTrashPath string
|
||||
for _, candidate := range trashed {
|
||||
if candidate != fixedTrashPath && filepath.Base(candidate) == filepath.Base(sessionPath) {
|
||||
uniqueTrashPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if uniqueTrashPath == "" {
|
||||
t.Fatalf("unique trash path not listed, got %#v", trashed)
|
||||
}
|
||||
if err := restoreTrashedSessionFile(dir, uniqueTrashPath); err != nil {
|
||||
t.Fatalf("restore unique trash item: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "new") {
|
||||
t.Fatalf("restored session = %q err=%v, want new content", string(got), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(uniqueTrashPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unique trash item should be removed after restore, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fixedTrashPath); err != nil {
|
||||
t.Fatalf("original fixed trash item should remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSessionTrashTargetKeepsDiscardableLiveStub(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "discardable-live.jsonl")
|
||||
@@ -789,6 +851,7 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
store.SessionConflictLog(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
@@ -826,6 +889,7 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
store.SessionConflictLog(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
@@ -910,6 +974,74 @@ func TestMovePathIfExistsCopyFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFallbackTreatsMissingSourceAsAlreadyMoved(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := copyAndRemove(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "dst.txt")); err != nil {
|
||||
t.Fatalf("copy missing source: %v", err)
|
||||
}
|
||||
|
||||
dstDir := filepath.Join(dir, "dst-dir")
|
||||
if err := copyDir(filepath.Join(dir, "missing-dir"), dstDir, 0o755); err != nil {
|
||||
t.Fatalf("copy missing dir: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dstDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("copy missing dir should not leave an empty target, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFallbackRemovesPartialTargetWhenSourceVanishesMidCopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
restore := copyPathFn
|
||||
copyPathFn = func(copySrc, copyDst string) error {
|
||||
// Simulate the source vanishing mid-copy: a truncated destination has
|
||||
// already been written when the copy fails.
|
||||
if err := os.WriteFile(copyDst, []byte("session tra"), 0o644); err != nil {
|
||||
t.Fatalf("write partial destination: %v", err)
|
||||
}
|
||||
if err := os.Remove(copySrc); err != nil {
|
||||
t.Fatalf("remove source: %v", err)
|
||||
}
|
||||
return errors.New("simulated read failure")
|
||||
}
|
||||
t.Cleanup(func() { copyPathFn = restore })
|
||||
|
||||
if err := copyAndRemove(src, dst); err != nil {
|
||||
t.Fatalf("copyAndRemove should treat vanished source as already moved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Fatalf("partial destination should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFallbackKeepsErrorWhenSourceStillExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
restore := copyPathFn
|
||||
simulated := errors.New("simulated copy failure")
|
||||
copyPathFn = func(copySrc, copyDst string) error {
|
||||
return simulated
|
||||
}
|
||||
t.Cleanup(func() { copyPathFn = restore })
|
||||
|
||||
if err := copyAndRemove(src, dst); !errors.Is(err, simulated) {
|
||||
t.Fatalf("copyAndRemove error = %v, want simulated copy failure", err)
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Fatalf("source should be untouched on real copy failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAndRemoveDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcDir := filepath.Join(dir, "src")
|
||||
|
||||
+22
-9
@@ -795,7 +795,7 @@ func (a *App) Settings() SettingsView {
|
||||
Ask: []string{},
|
||||
Deny: []string{},
|
||||
},
|
||||
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto"},
|
||||
Sandbox: SandboxView{Bash: config.Default().BashMode(), AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto"},
|
||||
Agent: AgentView{PlannerMaxSteps: 0, MaxSubagentDepth: agent.DefaultMaxSubagentDepth, ColdResumePrune: true, ReasoningLanguage: "auto"},
|
||||
Bot: botSettingsView(config.BotConfig{}),
|
||||
AutoPlan: "off",
|
||||
@@ -815,10 +815,7 @@ func (a *App) Settings() SettingsView {
|
||||
}
|
||||
}
|
||||
ctrl := a.activeCtrl()
|
||||
bash := cfg.Sandbox.Bash
|
||||
if bash == "" {
|
||||
bash = "enforce"
|
||||
}
|
||||
bash := cfg.BashMode()
|
||||
shell := cfg.Tools.Shell.Prefer
|
||||
if shell == "" {
|
||||
shell = "auto"
|
||||
@@ -1538,6 +1535,7 @@ func (a *App) rebuildSettingLocked(setting string) error {
|
||||
if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
|
||||
return err
|
||||
}
|
||||
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
||||
carried = oldCtrl.History()
|
||||
}
|
||||
snap := a.tabRuntimeSnapshot(tab)
|
||||
@@ -1565,10 +1563,14 @@ func (a *App) rebuildSettingLocked(setting string) error {
|
||||
})
|
||||
if err != nil {
|
||||
if oldCtrl == nil {
|
||||
leaseHeld := false
|
||||
a.mu.Lock()
|
||||
tab.StartupErr = err.Error()
|
||||
leaseHeld = setTabStartupError(tab, err)
|
||||
tab.Ready = true
|
||||
a.mu.Unlock()
|
||||
if leaseHeld {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
}
|
||||
a.emitReady(a.ctx)
|
||||
}
|
||||
return err
|
||||
@@ -1603,8 +1605,11 @@ func (a *App) rebuildSettingLocked(setting string) error {
|
||||
tab.Ctrl = ctrl
|
||||
tab.model = model
|
||||
tab.Label = ctrl.Label()
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = true
|
||||
// Supersede any in-flight startup build: it would otherwise finish later,
|
||||
// pass its generation check, and overwrite the controller just installed.
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
@@ -2372,9 +2377,13 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
|
||||
continue
|
||||
}
|
||||
tab.Ctrl = nil
|
||||
// Supersede any in-flight startup build: it was planned against the
|
||||
// removed provider and would otherwise finish later, pass its
|
||||
// generation check, and reinstall a controller for it.
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
tab.model = fallbackRef
|
||||
tab.Label = fallbackRef
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = a.ctx == nil
|
||||
if a.ctx != nil {
|
||||
rebuildTabs = append(rebuildTabs, tab)
|
||||
@@ -2487,9 +2496,13 @@ func (a *App) deleteProviderAndRetargetTabs(name string) error {
|
||||
continue
|
||||
}
|
||||
tab.Ctrl = nil
|
||||
// Supersede any in-flight startup build: it was planned against the
|
||||
// removed provider and would otherwise finish later, pass its
|
||||
// generation check, and reinstall a controller for it.
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
tab.model = fallbackRef
|
||||
tab.Label = fallbackRef
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = a.ctx == nil
|
||||
if a.ctx != nil {
|
||||
rebuildTabs = append(rebuildTabs, tab)
|
||||
|
||||
@@ -18,7 +18,7 @@ func sharedHostRefsForTest(t *testing.T, app *App, root string) (int, bool) {
|
||||
|
||||
func TestCloneDetachedRuntimeTabPreservesSharedHostKey(t *testing.T) {
|
||||
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "workspace-root"}
|
||||
detached := cloneDetachedRuntimeTab(tab, "session-key")
|
||||
detached := cloneDetachedRuntimeTab(tab, "session-key", "session-key")
|
||||
if detached == nil {
|
||||
t.Fatal("cloneDetachedRuntimeTab returned nil")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// TestRestoredTabGoalFollowsSessionGoalState pins the restart half of the
|
||||
// goal-reset contract: a typed /new or /clear rotates the session through the
|
||||
// controller (never touching App.NewSession/ClearSession), so the goal saved
|
||||
// in desktop-tabs.json can be stale. Startup restore must validate it against
|
||||
// the session's goal-state sidecar — which the rotation wrote as stopped on
|
||||
// the fresh path — instead of re-seeding the cleared goal via SetGoal.
|
||||
func TestRestoredTabGoalFollowsSessionGoalState(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "session.jsonl")
|
||||
|
||||
exec := agent.New(stubProvider{}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test", Sink: event.Discard})
|
||||
ctrl.SetGoal("finish the migration")
|
||||
|
||||
// Typed /new: controller-side rotation only.
|
||||
if err := ctrl.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
newPath := ctrl.SessionPath()
|
||||
ctrl.Close()
|
||||
|
||||
// desktop-tabs.json still carries the pre-rotation goal with the rotated
|
||||
// path (the tab profile was never told about the /new). Restore must not
|
||||
// resurrect it: the rotated session's sidecar says stopped.
|
||||
if got := runningTabSessionGoal(newPath, "finish the migration"); got != "" {
|
||||
t.Fatalf("restored goal for rotated session = %q, want empty", got)
|
||||
}
|
||||
|
||||
// The OLD session's sidecar still says running: restoring a tab that
|
||||
// points at the old session keeps its goal (resume semantics).
|
||||
if got := runningTabSessionGoal(oldPath, "finish the migration"); got != "finish the migration" {
|
||||
t.Fatalf("restored goal for old session = %q, want preserved", got)
|
||||
}
|
||||
|
||||
// A session without a goal-state sidecar keeps the persisted goal
|
||||
// (legacy sessions predating the sidecar).
|
||||
bare := filepath.Join(dir, "bare.jsonl")
|
||||
if err := os.WriteFile(bare, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write bare session: %v", err)
|
||||
}
|
||||
if got := runningTabSessionGoal(bare, "legacy goal"); got != "legacy goal" {
|
||||
t.Fatalf("restored goal without sidecar = %q, want fallback preserved", got)
|
||||
}
|
||||
}
|
||||
+599
-121
File diff suppressed because it is too large.
Load diff
+157
-2
@@ -32,6 +32,18 @@ func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
|
||||
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
|
||||
}
|
||||
|
||||
func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
|
||||
emit := func(context.Context, string, ...interface{}) {}
|
||||
if app != nil {
|
||||
app.runtimeEvents.emit = emit
|
||||
}
|
||||
for _, sink := range sinks {
|
||||
if sink != nil {
|
||||
sink.runtimeEvents.emit = emit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tabIDs(tabs []TabMeta) []string {
|
||||
ids := make([]string, 0, len(tabs))
|
||||
for _, tab := range tabs {
|
||||
@@ -909,8 +921,151 @@ func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
|
||||
if !tab.Ready {
|
||||
t.Fatal("tab should be ready with startup error")
|
||||
}
|
||||
if !strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) {
|
||||
t.Fatalf("startup error = %q, want lease-held error", tab.StartupErr)
|
||||
// The surfaced startup error is the sanitized busy message: the raw lease
|
||||
// error would leak the session path and the holder's host-pid-writer id
|
||||
// into the topbar banner.
|
||||
if !strings.Contains(tab.StartupErr, "already open in another Reasonix window") {
|
||||
t.Fatalf("startup error = %q, want user-facing busy message", tab.StartupErr)
|
||||
}
|
||||
if strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) ||
|
||||
strings.Contains(tab.StartupErr, path) {
|
||||
t.Fatalf("startup error leaked raw lease details: %q", tab.StartupErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredStartupRetryBuildsAfterLeaseRelease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
prevInterval := deferredRebuildRetryInterval
|
||||
deferredRebuildRetryInterval = 20 * time.Millisecond
|
||||
t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
|
||||
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "startup-retry.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
released := false
|
||||
t.Cleanup(func() {
|
||||
if !released {
|
||||
lease.Release()
|
||||
}
|
||||
})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
app.enableDeferredRebuildRetry()
|
||||
t.Cleanup(app.stopDeferredRebuildRetry)
|
||||
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_retry")
|
||||
tab.SessionPath = path
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if ctrl := app.controllerForTab(tab); ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
app.buildTabController(tab)
|
||||
if tab.Ctrl != nil {
|
||||
t.Fatalf("controller = %T, want nil while external lease is held", tab.Ctrl)
|
||||
}
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
|
||||
}
|
||||
if !app.deferredRebuildPending(tab.ID) {
|
||||
t.Fatal("startup retry was not scheduled while the lease was held")
|
||||
}
|
||||
|
||||
lease.Release()
|
||||
released = true
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if app.deferredRebuildPending(tab.ID) {
|
||||
t.Fatal("startup retry is still pending after the lease was released")
|
||||
}
|
||||
if ctrl := app.controllerForTab(tab); ctrl == nil {
|
||||
t.Fatal("controller was not rebuilt after the lease was released")
|
||||
}
|
||||
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup error after retry = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabAndCtrlByIDRecoversStartupLeaseBeforeAction(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "startup-before-action.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
released := false
|
||||
t.Cleanup(func() {
|
||||
if !released {
|
||||
lease.Release()
|
||||
}
|
||||
})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_action")
|
||||
tab.SessionPath = path
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if ctrl := app.controllerForTab(tab); ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
app.buildTabController(tab)
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
|
||||
}
|
||||
lease.Release()
|
||||
released = true
|
||||
|
||||
gotTab, ctrl := app.tabAndCtrlByID(tab.ID)
|
||||
if gotTab != tab {
|
||||
t.Fatalf("tabAndCtrlByID tab = %p, want %p", gotTab, tab)
|
||||
}
|
||||
if ctrl == nil {
|
||||
t.Fatal("tabAndCtrlByID did not rebuild the controller before returning")
|
||||
}
|
||||
if app.deferredRebuildPending(tab.ID) {
|
||||
t.Fatal("startup retry remained pending after synchronous recovery")
|
||||
}
|
||||
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup error after synchronous recovery = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,3 +199,201 @@ func TestContextPanelUsesLastUsageBreakdownWithTelemetryTotal(t *testing.T) {
|
||||
panel.CacheHitTokens, panel.CacheMissTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func costedUsageEvent() event.Event {
|
||||
return event.Event{
|
||||
Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140},
|
||||
Pricing: &provider.Pricing{CacheHit: 1, Input: 2, Output: 3, Currency: "¥"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTelemetryToSessionReKeysAcrossRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pathA := filepath.Join(dir, "a.jsonl")
|
||||
pathB := filepath.Join(dir, "b.jsonl")
|
||||
|
||||
tab := &WorkspaceTab{}
|
||||
tab.syncTelemetryToSession(pathA)
|
||||
tab.recordUsage(costedUsageEvent())
|
||||
costA := tab.telemetrySnapshot().Usage.SessionCost
|
||||
if costA <= 0 {
|
||||
t.Fatalf("seed cost = %f, want positive", costA)
|
||||
}
|
||||
if err := saveTelemetry(pathA+".telemetry.json", tab.telemetrySnapshot()); err != nil {
|
||||
t.Fatalf("save telemetry A: %v", err)
|
||||
}
|
||||
|
||||
// Same session: in-memory totals survive.
|
||||
tab.syncTelemetryToSession(pathA)
|
||||
if got := tab.telemetrySnapshot().Usage.SessionCost; got != costA {
|
||||
t.Fatalf("same-session sync cost = %f, want %f", got, costA)
|
||||
}
|
||||
|
||||
// Rotation to a session without a sidecar starts from zero — the previous
|
||||
// session's totals must not bleed over (#5850).
|
||||
tab.syncTelemetryToSession(pathB)
|
||||
if got := tab.telemetrySnapshot().Usage; got.SessionCost != 0 || got.TotalTokens != 0 || got.RequestCount != 0 {
|
||||
t.Fatalf("rotated telemetry = %+v, want zeroed", got)
|
||||
}
|
||||
|
||||
// Rotating back restores session A's persisted totals.
|
||||
tab.syncTelemetryToSession(pathA)
|
||||
if got := tab.telemetrySnapshot().Usage.SessionCost; got != costA {
|
||||
t.Fatalf("restored cost = %f, want %f", got, costA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUsageForTabReKeysAfterControllerRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rotated := filepath.Join(dir, "rotated.jsonl")
|
||||
stale := filepath.Join(dir, "stale.jsonl")
|
||||
|
||||
ag := agent.New(usageProvider{usage: &provider.Usage{}}, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab",
|
||||
Ctrl: control.New(control.Options{Executor: ag, Sink: event.Discard, SessionDir: dir, SessionPath: rotated}),
|
||||
}
|
||||
// Telemetry still keyed to the pre-rotation session: a typed /new routes
|
||||
// through Controller.Submit and rotates without App.NewSession running.
|
||||
tab.syncTelemetryToSession(stale)
|
||||
tab.recordUsage(costedUsageEvent())
|
||||
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
info := app.ContextUsageForTab("tab")
|
||||
if info.SessionCost != 0 || info.SessionTokens != 0 {
|
||||
t.Fatalf("context after rotation = cost %f tokens %d, want zeros", info.SessionCost, info.SessionTokens)
|
||||
}
|
||||
if got := tab.telemetrySnapshot().Usage.RequestCount; got != 0 {
|
||||
t.Fatalf("telemetry request count after rotation = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionResetsTabUsageTelemetry(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessPath := filepath.Join(dir, "session.jsonl")
|
||||
sess := agent.NewSession("sys")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "world"})
|
||||
exec := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: sessPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: exec,
|
||||
SessionDir: dir,
|
||||
SessionPath: sessPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
tab.syncTelemetryToSession(sessPath)
|
||||
tab.recordUsage(costedUsageEvent())
|
||||
if seed := tab.telemetrySnapshot().Usage.SessionCost; seed <= 0 {
|
||||
t.Fatalf("seed cost = %f, want positive", seed)
|
||||
}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
if got := tab.telemetrySnapshot().Usage; got.SessionCost != 0 || got.RequestCount != 0 || got.TotalTokens != 0 {
|
||||
t.Fatalf("telemetry after NewSession = %+v, want zeroed", got)
|
||||
}
|
||||
if info := app.ContextUsageForTab("tab"); info.SessionCost != 0 || info.SessionTokens != 0 {
|
||||
t.Fatalf("context after NewSession = cost %f tokens %d, want zeros", info.SessionCost, info.SessionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotConflictRecoveryCarriesTelemetryToFork(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
tab.syncTelemetryToSession(originalPath)
|
||||
tab.recordUsage(costedUsageEvent())
|
||||
want := tab.telemetrySnapshot().Usage.SessionCost
|
||||
if want <= 0 {
|
||||
t.Fatalf("seed cost = %f, want positive", want)
|
||||
}
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
|
||||
// The fork continues the conversation: in-memory totals carry over and a
|
||||
// later sync against the fork path must not wipe them.
|
||||
tab.syncTelemetryToSession(recoveryPath)
|
||||
if got := tab.telemetrySnapshot().Usage.SessionCost; got != want {
|
||||
t.Fatalf("carried cost = %f, want %f", got, want)
|
||||
}
|
||||
// The fork's sidecar was persisted at retarget time, so cost survives an
|
||||
// app exit before the next usage event.
|
||||
if got := loadTelemetry(recoveryPath + ".telemetry.json").Usage.SessionCost; got != want {
|
||||
t.Fatalf("fork sidecar cost = %f, want %f", got, want)
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,333 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
|
||||
topicID := "legacy_desktop-legacy_1234"
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
ID: agent.BranchID(sessionPath),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: "你是谁",
|
||||
Turns: 1,
|
||||
Preview: "who are you",
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta: %v", err)
|
||||
}
|
||||
markTopicMigrationDone(dir)
|
||||
|
||||
repaired := migrateLegacySessionsIntoGlobalTopics(dir)
|
||||
if len(repaired) != 1 || repaired[0] != topicID {
|
||||
t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
var global *ProjectNode
|
||||
for i := range nodes {
|
||||
if nodes[i].Kind == "global_folder" {
|
||||
global = &nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if global == nil {
|
||||
t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
|
||||
}
|
||||
if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
|
||||
t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if !containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
|
||||
}
|
||||
if got := loadTopicTitle("", topicID); got != "你是谁" {
|
||||
t.Fatalf("global topic title = %q, want 你是谁", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
|
||||
topicID := "legacy_desktop-delete_1234"
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
ID: agent.BranchID(sessionPath),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: "临时 Global",
|
||||
Turns: 1,
|
||||
Preview: "delete repaired topic",
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta: %v", err)
|
||||
}
|
||||
markTopicMigrationDone(dir)
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
|
||||
t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
|
||||
}
|
||||
if err := NewApp().DeleteTopic(topicID); err != nil {
|
||||
t.Fatalf("delete repaired topic: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
later := time.Now()
|
||||
if err := os.Chtimes(sessionPath, later, later); err != nil {
|
||||
t.Fatalf("touch session after delete: %v", err)
|
||||
}
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
|
||||
t.Fatalf("deleted repaired topic was restored: %#v", repaired)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
|
||||
}
|
||||
if got := loadTopicTitle("", topicID); got != "" {
|
||||
t.Fatalf("global topic title = %q, want deleted", got)
|
||||
}
|
||||
if !containsDesktopString(f.DeletedTopics, topicID) {
|
||||
t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
writeIndexedSession := func(name, topicID, title string, at time.Time) string {
|
||||
p := writeLegacySession(t, dir, name, "prompt "+title, at)
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
|
||||
ID: agent.BranchID(p),
|
||||
CreatedAt: at.Add(-time.Hour),
|
||||
UpdatedAt: at,
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: title,
|
||||
Turns: 1,
|
||||
Preview: "prompt " + title,
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta %s: %v", name, err)
|
||||
}
|
||||
if err := os.Chtimes(p, at, at); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
now := time.Now()
|
||||
olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
|
||||
writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
|
||||
markTopicMigrationDone(dir)
|
||||
|
||||
// First pass repairs both missing topics.
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
|
||||
t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
|
||||
}
|
||||
before := loadProjectsFile()
|
||||
projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
|
||||
statBefore, err := os.Stat(projPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat projects file: %v", err)
|
||||
}
|
||||
|
||||
// Ordinary session activity invalidates the repair marker; the rescan must
|
||||
// not reorder the indexed topics, rewrite the projects file, or report the
|
||||
// already-visible topics as repaired (the callers bind blank Global tabs
|
||||
// to repaired[0]).
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
later := time.Now()
|
||||
if err := os.Chtimes(olderPath, later, later); err != nil {
|
||||
t.Fatalf("touch session: %v", err)
|
||||
}
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
|
||||
t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
|
||||
}
|
||||
after := loadProjectsFile()
|
||||
if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
|
||||
t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
|
||||
}
|
||||
statAfter, err := os.Stat(projPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat projects file: %v", err)
|
||||
}
|
||||
if !statAfter.ModTime().Equal(statBefore.ModTime()) {
|
||||
t.Fatalf("steady-state rescan rewrote the projects file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPrependRespectsTombstoneWrittenAfterScanSnapshot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
topicID := "legacy_race_000000000001"
|
||||
// Simulate a DeleteTopic landing between a repair scan's DeletedTopics
|
||||
// snapshot and its batch write: the tombstone exists by the time the
|
||||
// prepend runs, so the batch must drop the topic instead of resurrecting it.
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
|
||||
t.Fatalf("batch prepend: %v", err)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
|
||||
}
|
||||
if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
|
||||
t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
|
||||
}
|
||||
if !containsDesktopString(f.DeletedTopics, topicID) {
|
||||
t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
|
||||
}
|
||||
|
||||
// Intentional single-topic writes (create/restore/tab indexing) clear the
|
||||
// tombstone and bring the topic back in the same projects-file transaction.
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("single prepend: %v", err)
|
||||
}
|
||||
f = loadProjectsFile()
|
||||
if !containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
|
||||
}
|
||||
if containsDesktopString(f.DeletedTopics, topicID) {
|
||||
t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
// Control: a legitimate title-only topic (not in GlobalTopics) must keep
|
||||
// rendering through the orderedTopicIDs title-map fallback.
|
||||
controlID := "topic_control_visible"
|
||||
if err := setTopicTitle("", controlID, "正常话题"); err != nil {
|
||||
t.Fatalf("set control title: %v", err)
|
||||
}
|
||||
// Race product: DeleteTopic landed, but a stale whole-map save wrote the
|
||||
// topic's title back — tombstoned, absent from GlobalTopics, title present.
|
||||
tombstonedID := "legacy_raced_000000000009"
|
||||
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
|
||||
t.Fatalf("set stale title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
var global *ProjectNode
|
||||
for i := range nodes {
|
||||
if nodes[i].Kind == "global_folder" {
|
||||
global = &nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if global == nil {
|
||||
t.Fatalf("project tree = %#v, want Global folder", nodes)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range global.Children {
|
||||
seen[c.TopicID] = true
|
||||
}
|
||||
if seen[tombstonedID] {
|
||||
t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
|
||||
}
|
||||
if !seen[controlID] {
|
||||
t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
// Stale race product on disk: tombstoned topic whose title lingers in the
|
||||
// global title map. The next repair pass that saves the whole map must
|
||||
// prune it instead of persisting it again.
|
||||
tombstonedID := "legacy_raced_000000000010"
|
||||
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
|
||||
t.Fatalf("set stale title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
|
||||
repairID := "legacy_desktop-prune_1234"
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
ID: agent.BranchID(sessionPath),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: repairID,
|
||||
TopicTitle: "待修复话题",
|
||||
Turns: 1,
|
||||
Preview: "needs repair",
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta: %v", err)
|
||||
}
|
||||
markTopicMigrationDone(dir)
|
||||
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
|
||||
t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
|
||||
}
|
||||
if got := loadTopicTitle("", tombstonedID); got != "" {
|
||||
t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
|
||||
}
|
||||
if got := loadTopicTitle("", repairID); got != "待修复话题" {
|
||||
t.Fatalf("repaired title = %q, want 待修复话题", got)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
|
||||
}
|
||||
if !containsDesktopString(f.GlobalTopics, repairID) {
|
||||
t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
@@ -1773,6 +2100,54 @@ func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicRemovesStaleMissingSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_missing_trash"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Missing trash"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
missingPath := filepath.Join(dir, "already-gone.jsonl")
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"stale": {
|
||||
ID: "stale",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Missing trash",
|
||||
SessionPath: missingPath,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
"other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
|
||||
},
|
||||
tabOrder: []string{"stale", "other"},
|
||||
activeTabID: "stale",
|
||||
}
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("TrashTopic should remove stale missing session: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("topic title should be removed, got %q", got)
|
||||
}
|
||||
if _, ok := app.tabs["stale"]; ok {
|
||||
t.Fatalf("stale tab should be removed")
|
||||
}
|
||||
if got := app.activeTabID; got != "other" {
|
||||
t.Fatalf("active tab = %q, want other", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
+40
-13
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -34,11 +35,11 @@ import (
|
||||
// the thin Wails binding that wires these into App methods and progress events.
|
||||
|
||||
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
|
||||
// worker release gateway. The build channel picks the rolling pointer so a
|
||||
// canary build polls the canary line and a stable build polls latest; the two
|
||||
// never cross. The gateway deliberately avoids GitHub's repository-wide
|
||||
// /releases/latest shortcut because CLI tags (v*) and desktop tags (desktop-v*)
|
||||
// are separate release lines in the same repo.
|
||||
// worker release gateway, then GitHub as the stable channel's last resort. The
|
||||
// build channel picks the rolling pointer so a canary build polls the canary
|
||||
// line and a stable build polls latest; the two never cross. The gateway still
|
||||
// avoids GitHub's repository-wide /releases/latest shortcut so the app is not
|
||||
// coupled to GitHub's homepage badge semantics.
|
||||
const (
|
||||
r2Base = "https://dl.reasonix.io"
|
||||
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
|
||||
@@ -46,8 +47,18 @@ const (
|
||||
httpTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
|
||||
// for the running build's channel.
|
||||
// githubManifestFallback is the stable channel's last-resort manifest source.
|
||||
// dl.reasonix.io and crash.reasonix.io share one Cloudflare zone, so bot
|
||||
// protection that 403s a user's egress IP takes out both first-party endpoints
|
||||
// at once (#6005); GitHub is separate infrastructure. Stable desktop releases
|
||||
// own the repo-wide latest badge and publish latest.json directly, while
|
||||
// release.yml also keeps a desktop-manifest mirror attached to stable CLI
|
||||
// releases for older publishing windows. Canary has no GitHub release, so its
|
||||
// chain stays two-deep.
|
||||
const githubManifestFallback = "https://github.com/esengine/DeepSeek-Reasonix/releases/latest/download/latest.json"
|
||||
|
||||
// manifestEndpoints returns the manifest URLs for the running build's channel,
|
||||
// in the order fetchManifest tries them.
|
||||
func manifestEndpoints() []string {
|
||||
if channel == "canary" {
|
||||
return []string{
|
||||
@@ -58,9 +69,18 @@ func manifestEndpoints() []string {
|
||||
return []string{
|
||||
r2Base + "/latest/latest.json",
|
||||
releaseGatewayBase + "/stable/latest.json",
|
||||
githubManifestFallback,
|
||||
}
|
||||
}
|
||||
|
||||
// updaterUserAgent identifies updater traffic. Go's default Go-http-client UA
|
||||
// is exactly what edge bot protection scores worst (#6005); a descriptive UA
|
||||
// lets the release edge allowlist updater requests and makes them attributable
|
||||
// in server logs.
|
||||
func updaterUserAgent() string {
|
||||
return fmt.Sprintf("Reasonix-Updater/%s (%s/%s; %s)", version, runtime.GOOS, runtime.GOARCH, channel)
|
||||
}
|
||||
|
||||
// downloadPage is the human-facing releases page shown when self-update is
|
||||
// unavailable (macOS) or the manifest omits its own link.
|
||||
func downloadPage() string {
|
||||
@@ -147,24 +167,26 @@ func normalizeVersion(v string) (string, bool) {
|
||||
return semver.Canonical(v), true
|
||||
}
|
||||
|
||||
// fetchManifest pulls latest.json from the primary endpoint, then the fallback,
|
||||
// and decodes it.
|
||||
// fetchManifest pulls latest.json from each endpoint in order until one both
|
||||
// responds and decodes. Every endpoint's failure is kept — a user staring at a
|
||||
// gateway 403 (#6005) needs to see that the R2 pointer failed too, not just
|
||||
// whichever endpoint happened to die last.
|
||||
func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error) {
|
||||
var lastErr error
|
||||
var errs []error
|
||||
for _, url := range manifestEndpoints() {
|
||||
b, err := fetchBytes(ctx, c, url)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
var m update.Manifest
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
lastErr = err
|
||||
errs = append(errs, fmt.Errorf("%s: %w", url, err))
|
||||
continue
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
return nil, fmt.Errorf("update: fetch manifest: %w", lastErr)
|
||||
return nil, fmt.Errorf("update: fetch manifest: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// evaluate compares the running version against the manifest and builds the
|
||||
@@ -214,6 +236,9 @@ type cachedUpdate struct {
|
||||
var updateCacheBaseDir = defaultUpdateCacheBaseDir
|
||||
|
||||
func defaultUpdateCacheBaseDir() (string, error) {
|
||||
if cd := config.CacheDir(); cd != "" {
|
||||
return filepath.Join(cd, "updates"), nil
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
base = os.TempDir()
|
||||
@@ -426,6 +451,7 @@ func fetchBytesOnce(ctx context.Context, c *http.Client, url string) ([]byte, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", updaterUserAgent())
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -467,6 +493,7 @@ func downloadInto(ctx context.Context, c *http.Client, url string, buf *bytes.Bu
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", updaterUserAgent())
|
||||
if buf.Len() > 0 {
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", buf.Len()))
|
||||
}
|
||||
|
||||
@@ -101,7 +101,14 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
|
||||
if stable[1] != releaseGatewayBase+"/stable/latest.json" {
|
||||
t.Errorf("stable fallback = %q, want the release gateway", stable[1])
|
||||
}
|
||||
for _, u := range append(stable, canary...) {
|
||||
// GitHub is stable's explicit last resort only (#6005: both first-party
|
||||
// endpoints share one Cloudflare zone). Stable desktop releases own the
|
||||
// repo-wide latest release and carry latest.json directly; no other slot may
|
||||
// lean on repository-wide latest.
|
||||
if len(stable) != 3 || stable[2] != githubManifestFallback {
|
||||
t.Errorf("stable endpoints = %q, want the GitHub compatibility manifest last", stable)
|
||||
}
|
||||
for _, u := range append(stable[:2:2], canary...) {
|
||||
if strings.Contains(u, "/releases/latest") {
|
||||
t.Errorf("manifest endpoint uses GitHub's repository-wide latest release: %q", u)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
|
||||
// The Windows bash sandbox relaunches os.Executable() with
|
||||
// sandbox.WindowsHelperCommand as argv[1]. If the desktop binary loses this
|
||||
// route, every sandboxed command on Windows starts a second GUI instance and
|
||||
// returns empty output (#6051, #6067, #6072). These tests pin the route.
|
||||
|
||||
func TestWindowsSandboxHelperRouteRecognized(t *testing.T) {
|
||||
// argv[2:] is empty, so the helper rejects it with a usage error — the
|
||||
// point here is only that the route matched (ok == true) and the process
|
||||
// would exit instead of booting the GUI.
|
||||
code, ok := runWindowsSandboxHelperIfRequested([]string{"reasonix-desktop", sandbox.WindowsHelperCommand})
|
||||
if !ok {
|
||||
t.Fatal("helper subcommand not routed; sandboxed commands would boot a second GUI instance")
|
||||
}
|
||||
if code == 0 {
|
||||
t.Fatalf("helper with no payload should fail with a usage error, got exit code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsSandboxHelperRouteIgnoresNormalLaunch(t *testing.T) {
|
||||
for _, argv := range [][]string{
|
||||
{"reasonix-desktop"},
|
||||
{"reasonix-desktop", "--some-flag"},
|
||||
{"reasonix-desktop", "not-the-helper", sandbox.WindowsHelperCommand},
|
||||
} {
|
||||
if _, ok := runWindowsSandboxHelperIfRequested(argv); ok {
|
||||
t.Fatalf("argv %v should not be treated as a helper launch", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,9 @@ type workspaceChangeAccumulator struct {
|
||||
const workspaceGitBranchCacheTTL = 2 * time.Second
|
||||
|
||||
type workspaceGitBranchCacheEntry struct {
|
||||
branch string
|
||||
expires time.Time
|
||||
branch string
|
||||
expires time.Time
|
||||
refreshing bool
|
||||
}
|
||||
|
||||
var workspaceGitBranchCache = struct {
|
||||
@@ -39,6 +40,8 @@ var workspaceGitBranchCache = struct {
|
||||
entries map[string]workspaceGitBranchCacheEntry
|
||||
}{entries: map[string]workspaceGitBranchCacheEntry{}}
|
||||
|
||||
var workspaceGitBranchForMetaProbe = workspaceGitBranch
|
||||
|
||||
func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
|
||||
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
@@ -245,32 +248,58 @@ func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
|
||||
}
|
||||
|
||||
// workspaceGitBranchForMeta is the cached variant used by high-frequency UI
|
||||
// metadata refreshes. Workflows that need an immediate git read, such as
|
||||
// WorkspaceChanges, should call workspaceGitBranch directly.
|
||||
// metadata refreshes. It never waits for git on the caller path: stale branch
|
||||
// metadata is less harmful than blocking tab activation or hydration. Workflows
|
||||
// that need an immediate git read, such as WorkspaceChanges, should call
|
||||
// workspaceGitBranch directly.
|
||||
func workspaceGitBranchForMeta(base string) string {
|
||||
key := filepath.Clean(base)
|
||||
now := time.Now()
|
||||
|
||||
workspaceGitBranchCache.Lock()
|
||||
if cached, ok := workspaceGitBranchCache.entries[key]; ok && now.Before(cached.expires) {
|
||||
if cached, ok := workspaceGitBranchCache.entries[key]; ok {
|
||||
branch := cached.branch
|
||||
if now.Before(cached.expires) || cached.refreshing {
|
||||
workspaceGitBranchCache.Unlock()
|
||||
return branch
|
||||
}
|
||||
cached.refreshing = true
|
||||
workspaceGitBranchCache.entries[key] = cached
|
||||
workspaceGitBranchCache.Unlock()
|
||||
return cached.branch
|
||||
go refreshWorkspaceGitBranchForMeta(key, base)
|
||||
return branch
|
||||
}
|
||||
|
||||
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{
|
||||
expires: now.Add(workspaceGitBranchCacheTTL),
|
||||
refreshing: true,
|
||||
}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
|
||||
branch := workspaceGitBranch(base)
|
||||
go refreshWorkspaceGitBranchForMeta(key, base)
|
||||
return ""
|
||||
}
|
||||
|
||||
storeNow := time.Now()
|
||||
workspaceGitBranchCache.Lock()
|
||||
if len(workspaceGitBranchCache.entries) > 256 {
|
||||
for k, cached := range workspaceGitBranchCache.entries {
|
||||
if storeNow.After(cached.expires) {
|
||||
delete(workspaceGitBranchCache.entries, k)
|
||||
func refreshWorkspaceGitBranchForMeta(key, base string) {
|
||||
branch := ""
|
||||
// Store via defer so the refreshing flag is always cleared, even when the
|
||||
// probe panics or exits the goroutine early; otherwise the entry would stay
|
||||
// marked refreshing forever and never update again.
|
||||
defer func() {
|
||||
storeNow := time.Now()
|
||||
workspaceGitBranchCache.Lock()
|
||||
if len(workspaceGitBranchCache.entries) > 256 {
|
||||
for k, cached := range workspaceGitBranchCache.entries {
|
||||
if storeNow.After(cached.expires) {
|
||||
delete(workspaceGitBranchCache.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
return branch
|
||||
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
}()
|
||||
|
||||
branch = workspaceGitBranchForMetaProbe(base)
|
||||
}
|
||||
|
||||
// workspaceGitBranch returns the current git branch name for the repo rooted
|
||||
|
||||
@@ -3,9 +3,12 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkspaceGitDisablesDaemonSpawns(t *testing.T) {
|
||||
@@ -114,3 +117,97 @@ func TestWorkspaceGitBranchNonGitDirectory(t *testing.T) {
|
||||
t.Fatalf("branch = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
|
||||
resetWorkspaceGitBranchMetaCacheForTest(t)
|
||||
origProbe := workspaceGitBranchForMetaProbe
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
workspaceGitBranchForMetaProbe = func(string) string {
|
||||
close(started)
|
||||
<-release
|
||||
return "feature/async"
|
||||
}
|
||||
defer func() {
|
||||
releaseProbe()
|
||||
workspaceGitBranchForMetaProbe = origProbe
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe"); got != "" {
|
||||
t.Fatalf("cold branch = %q, want empty while async refresh runs", got)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("cold metadata branch probe blocked for %s", elapsed)
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background branch refresh did not start")
|
||||
}
|
||||
|
||||
releaseProbe()
|
||||
eventuallyBranchForMeta(t, "/tmp/reasonix-cold-probe", "feature/async")
|
||||
}
|
||||
|
||||
func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
|
||||
resetWorkspaceGitBranchMetaCacheForTest(t)
|
||||
workspaceGitBranchCache.Lock()
|
||||
workspaceGitBranchCache.entries[filepath.Clean("/tmp/reasonix-stale-probe")] = workspaceGitBranchCacheEntry{
|
||||
branch: "feature/stale",
|
||||
expires: time.Now().Add(-time.Second),
|
||||
}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
|
||||
origProbe := workspaceGitBranchForMetaProbe
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
workspaceGitBranchForMetaProbe = func(string) string {
|
||||
close(started)
|
||||
<-release
|
||||
return "feature/fresh"
|
||||
}
|
||||
defer func() {
|
||||
releaseProbe()
|
||||
workspaceGitBranchForMetaProbe = origProbe
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe"); got != "feature/stale" {
|
||||
t.Fatalf("stale branch = %q, want feature/stale", got)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("stale metadata branch probe blocked for %s", elapsed)
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background branch refresh did not start")
|
||||
}
|
||||
|
||||
releaseProbe()
|
||||
eventuallyBranchForMeta(t, "/tmp/reasonix-stale-probe", "feature/fresh")
|
||||
}
|
||||
|
||||
func resetWorkspaceGitBranchMetaCacheForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
workspaceGitBranchCache.Lock()
|
||||
workspaceGitBranchCache.entries = map[string]workspaceGitBranchCacheEntry{}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
}
|
||||
|
||||
func eventuallyBranchForMeta(t *testing.T, base, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := workspaceGitBranchForMeta(base); got == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("branch did not refresh to %q", want)
|
||||
}
|
||||
@@ -7,6 +7,10 @@ here. Snapshot-based and aligned with Claude Code. An optional git-backed mode i
|
||||
the remaining (lower-priority) follow-up. Tracks the most requested missing
|
||||
capability from v1 — an edit safety net / undo.
|
||||
|
||||
This document describes rewind snapshots. For the autonomous-run rule about when
|
||||
the agent should pause and ask the user, see
|
||||
[`TASK_CONTRACT.md`](TASK_CONTRACT.md).
|
||||
|
||||
## Goal
|
||||
|
||||
Let a user rewind a session to a previous point and restore **code**,
|
||||
|
||||
@@ -52,6 +52,13 @@ Reasonix 桌面端输入框左下角的“协作方式”菜单包含三类常
|
||||
“不要原地打转”。这类目标会自动进入 AutoResearch 策略,把状态写到
|
||||
`.reasonix/autoresearch/<task-id>/`。
|
||||
|
||||
### 推荐目标写法
|
||||
|
||||
复杂目标可以写成一份任务合约:Context、Request、Output format、Constraints、Pause policy。
|
||||
Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆或对外可见操作、任务范围变化,
|
||||
或需要你提供信息,否则会继续完成任务后再汇报。完整模板见
|
||||
[`TASK_CONTRACT.zh-CN.md`](./TASK_CONTRACT.zh-CN.md)。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。
|
||||
|
||||
+20
-4
@@ -15,6 +15,17 @@ location.
|
||||
Set `REASONIX_HOME` to override Reasonix home for tests, CI, or portable
|
||||
installations. Normal users should not need it.
|
||||
|
||||
When `REASONIX_HOME` is set, the runtime is fully self-contained: all
|
||||
configuration, state, cache, and data live under that directory tree. Legacy
|
||||
migration, OS-home convention directory scanning, and all other fallback paths
|
||||
are skipped so no data leaks in from a system-wide production install.
|
||||
|
||||
Advanced test and portable setups may set `REASONIX_STATE_HOME` to move runtime
|
||||
state such as sessions, archives, and memory. It does not move global config or
|
||||
provider credentials: those remain under `REASONIX_HOME`. If an older build wrote
|
||||
provider keys to `REASONIX_STATE_HOME/.env`, Reasonix imports those keys
|
||||
non-destructively when `<Reasonix home>/.env` is missing them.
|
||||
|
||||
## What Lives There
|
||||
|
||||
| Data | Path |
|
||||
@@ -26,9 +37,12 @@ installations. Normal users should not need it.
|
||||
| Global skills | `<Reasonix home>/skills/` |
|
||||
| Global hooks | `<Reasonix home>/settings.json` |
|
||||
| Hook trust store | `<Reasonix home>/trust.json` |
|
||||
| Sessions | `<Reasonix home>/sessions/` |
|
||||
| Archives | `<Reasonix home>/archive/` |
|
||||
| Memory | `<Reasonix home>/memory/` and `<Reasonix home>/projects/` |
|
||||
| Sessions | `<state root>/sessions/` |
|
||||
| Archives | `<state root>/archive/` |
|
||||
| Memory | `<state root>/memory/` and `<state root>/projects/` |
|
||||
|
||||
`<state root>` defaults to `<Reasonix home>`. It only differs when
|
||||
`REASONIX_STATE_HOME` is set.
|
||||
|
||||
The global user config is named `config.toml`. Project-local config files keep
|
||||
the name `reasonix.toml`. If someone says "global reasonix.toml", they usually
|
||||
@@ -165,7 +179,9 @@ into the process environment, and Reasonix control variables such as
|
||||
Caches remain in the OS cache directory, for example
|
||||
`~/Library/Caches/reasonix` on macOS, `$XDG_CACHE_HOME/reasonix` or
|
||||
`~/.cache/reasonix` on Linux, and `%LOCALAPPDATA%\reasonix\cache` on Windows.
|
||||
Set `REASONIX_CACHE_HOME` to override the cache root.
|
||||
Set `REASONIX_CACHE_HOME` to override the cache root. When `REASONIX_HOME` is
|
||||
set, the cache is placed under `$REASONIX_HOME/cache` (unless
|
||||
`REASONIX_CACHE_HOME` is also set, which takes precedence).
|
||||
|
||||
## Config Priority
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
|
||||
可以设置 `REASONIX_HOME` 覆盖 Reasonix home,主要用于测试、CI 或便携安装。普通用户通常不需要设置。
|
||||
|
||||
设置 `REASONIX_HOME` 后,运行时会变成完整自包含模式:配置、状态、缓存和数据都会位于该目录树下。
|
||||
Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳过,避免从系统级正式安装带入或写回数据。
|
||||
|
||||
高级测试或便携安装可以设置 `REASONIX_STATE_HOME` 来移动 sessions、archive、memory 等运行状态。
|
||||
它不会移动全局配置或 provider 凭据;这些仍然位于 `REASONIX_HOME` 下。如果旧版本曾把 provider key
|
||||
写到 `REASONIX_STATE_HOME/.env`,Reasonix 会在 `<Reasonix home>/.env` 缺少对应 key 时非破坏性导入。
|
||||
|
||||
## 目录内容
|
||||
|
||||
| 数据 | 路径 |
|
||||
@@ -23,9 +30,12 @@
|
||||
| 全局 skills | `<Reasonix home>/skills/` |
|
||||
| 全局 hooks | `<Reasonix home>/settings.json` |
|
||||
| hooks 信任状态 | `<Reasonix home>/trust.json` |
|
||||
| 会话 | `<Reasonix home>/sessions/` |
|
||||
| 归档 | `<Reasonix home>/archive/` |
|
||||
| 记忆 | `<Reasonix home>/memory/` 与 `<Reasonix home>/projects/` |
|
||||
| 会话 | `<state root>/sessions/` |
|
||||
| 归档 | `<state root>/archive/` |
|
||||
| 记忆 | `<state root>/memory/` 与 `<state root>/projects/` |
|
||||
|
||||
`<state root>` 默认等于 `<Reasonix home>`;只有设置 `REASONIX_STATE_HOME`
|
||||
时才会不同。
|
||||
|
||||
全局用户配置文件名是 `config.toml`。项目本地配置文件仍叫 `reasonix.toml`。
|
||||
如果有人说“全局 reasonix.toml”,通常指的是 `<Reasonix home>/config.toml`。
|
||||
@@ -135,6 +145,8 @@ Provider 请求只会从这个全局 `.env` 解析 key。项目 `.env`、home `.
|
||||
缓存仍放在系统缓存目录,例如 macOS 的 `~/Library/Caches/reasonix`、
|
||||
Linux 的 `$XDG_CACHE_HOME/reasonix` 或 `~/.cache/reasonix`、Windows 的
|
||||
`%LOCALAPPDATA%\reasonix\cache`。可以设置 `REASONIX_CACHE_HOME` 覆盖缓存根目录。
|
||||
设置 `REASONIX_HOME` 后,缓存会放在 `$REASONIX_HOME/cache`;如果同时设置
|
||||
`REASONIX_CACHE_HOME`,后者优先。
|
||||
|
||||
## 配置优先级
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ Reasonix 的 Goal 模式(`/goal`)在 v1.9+ 中增加了来自 Oh-My-OpenAgen
|
||||
- 需要完整测试覆盖的任务
|
||||
- 多步骤复杂流程
|
||||
|
||||
### 任务合约
|
||||
|
||||
复杂目标可以直接写成 Context / Request / Output format / Constraints /
|
||||
Pause policy。Goal 模式会把这些段落当作执行边界:满足请求、输出格式、约束和必要验证后才结束;
|
||||
除非下一步涉及不可逆或对外可见操作、范围变化,或必须由用户提供信息,否则继续采用合理默认值推进。
|
||||
|
||||
### 并行子任务
|
||||
|
||||
```bash
|
||||
|
||||
+51
-8
@@ -16,6 +16,7 @@
|
||||
- [Serve web frontend](#serve-web-frontend)
|
||||
- [Configuration paths](./CONFIG_PATHS.md)
|
||||
- [Reasoning language](./REASONING_LANGUAGE.md)
|
||||
- [Task contracts and pause policy](./TASK_CONTRACT.md)
|
||||
- [Custom OpenAI-compatible providers](#custom-openai-compatible-providers)
|
||||
- [Desktop hooks](#desktop-hooks)
|
||||
- [Keyboard shortcuts](#keyboard-shortcuts)
|
||||
@@ -479,16 +480,51 @@ out. `forbid_read` optionally hides sensitive directories from the agent's
|
||||
read/list/search tools; use absolute paths or `${HOME}` / `${VAR}` references,
|
||||
not `~`, because config expansion is environment-variable based. `bash` is
|
||||
itself jailed by default when an OS sandbox is available (`[sandbox] bash`,
|
||||
Seatbelt on macOS and bubblewrap on Linux): commands may write only those same
|
||||
roots (plus temp and toolchain caches), cannot read configured `forbid_read`
|
||||
roots while the OS sandbox is active, and reach the network only when
|
||||
`[sandbox] network` is set. When no OS sandbox is available, `bash = "enforce"`
|
||||
refuses bash execution instead of running unconfined. Install the platform
|
||||
sandbox backend (bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
|
||||
Seatbelt on macOS, bubblewrap on Linux, and a native helper on Windows):
|
||||
commands may write only those same roots plus platform-specific command
|
||||
temp/cache roots, cannot read configured `forbid_read` roots while the OS
|
||||
sandbox is active, and reach the network only when `[sandbox] network` is set.
|
||||
The native Windows helper uses Reasonix's bundled Windows sandbox backend:
|
||||
AppContainer for read-only commands and a low-integrity token for writable
|
||||
commands, temporarily grants
|
||||
access to the workspace, a per-command temp root, and the target executable,
|
||||
applies deny ACEs for `forbid_read` (files as well as directories), snapshots
|
||||
touched DACLs before editing them, and restores those snapshots best-effort
|
||||
after the command exits. Concurrent commands touching the same workspace are
|
||||
serialized so their ACL edits cannot corrupt each other, and residue from a
|
||||
force-killed command (a lingering low-integrity label or `forbid_read` deny) is
|
||||
cleaned up by the next run. Because a writable command runs under a
|
||||
low-integrity token, it can still write the few locations Windows leaves
|
||||
writable to any low-integrity process (for example `%USERPROFILE%\AppData\LocalLow`)
|
||||
in addition to the configured roots; the workspace boundary and `forbid_read`
|
||||
denials still hold. Read-only AppContainer commands omit network capabilities
|
||||
when networking is disabled; writable Windows commands fail closed when
|
||||
`[sandbox] network = false`.
|
||||
When no OS sandbox backend is available, `bash = "enforce"` refuses bash
|
||||
execution instead of running unconfined. Install the platform sandbox backend
|
||||
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
|
||||
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
|
||||
behavior (see
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt and
|
||||
broader OS support still to come).
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt
|
||||
and optional elevated Windows hardening still to come).
|
||||
|
||||
Windows sandbox troubleshooting: the sandbox relaunches the Reasonix
|
||||
executable as a hidden helper, and both the CLI and the desktop app embed that
|
||||
helper entry point — if enforce is requested in a build that lacks it, bash
|
||||
refuses with a clear error instead of returning empty output. A command that
|
||||
queues behind another sandboxed command on the same workspace prints a
|
||||
one-line "waiting for another sandboxed command" notice that names the holding
|
||||
command and its PID when known. A foreground command gives up after 1 minute
|
||||
with the same holder detail (a queued turn should fail fast, not hang);
|
||||
background jobs wait up to 10 minutes, and `WINDOWS_SANDBOX_LOCK_MS` overrides
|
||||
both. Stop the named command first; raising the wait cap only makes later
|
||||
commands wait longer. If sandboxed commands fail
|
||||
only under Git-for-Windows/MSYS2 bash, try `[tools.shell] prefer =
|
||||
"powershell"` — the MSYS runtime is fragile under a low-integrity token. Run
|
||||
`reasonix doctor` to see the resolved shell, sandbox availability, and whether
|
||||
a project `reasonix.toml` pins `[sandbox]` (a project file overrides
|
||||
Settings/user-config edits, and sandbox changes take effect after a session
|
||||
config reload or a new session).
|
||||
|
||||
## Plugins (MCP)
|
||||
|
||||
@@ -662,6 +698,13 @@ skill in Settings -> Skills or the slash menu. If an ordinary chat prompt has a
|
||||
very strong long-horizon signal, the host also upgrades it into the equivalent
|
||||
of `/goal --research <original prompt>`.
|
||||
|
||||
For complex work, write the objective as a
|
||||
[task contract](./TASK_CONTRACT.md): Context, Request, Output format,
|
||||
Constraints, and Pause policy. Goal mode treats those sections as the boundary
|
||||
for autonomous work. It keeps going with sensible defaults unless the next step
|
||||
requires an irreversible or externally visible operation, a scope change, or
|
||||
information only the user can provide.
|
||||
|
||||
AutoResearch is enabled for goals with strong signals such as "keep
|
||||
researching", "long-running", "thoroughly", "debug until the root cause is
|
||||
clear", "do not spin", "run experiments", "verify repeatedly", or "turn this
|
||||
|
||||
+32
-3
@@ -16,6 +16,7 @@
|
||||
- [Serve Web 前端](#serve-web-前端)
|
||||
- [配置路径](./CONFIG_PATHS.zh-CN.md)
|
||||
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
|
||||
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
|
||||
- [自定义 OpenAI-compatible provider](#自定义-openai-compatible-provider)
|
||||
- [桌面端 Hooks](./DESKTOP_HOOKS.zh-CN.md)
|
||||
- [快捷键](#快捷键)
|
||||
@@ -391,11 +392,35 @@ CJK 双宽字符,造成视觉错位。想保留旧的终端块状光标可设
|
||||
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
|
||||
打洞越界。`forbid_read` 可选地隐藏敏感目录,使 agent 的读文件、列目录和搜索工具不能读取或列出它们;
|
||||
建议使用绝对路径或 `${HOME}` / `${VAR}`,不要写 `~`,因为配置只做环境变量展开。
|
||||
`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):命令只能写这些 root(外加临时目录与工具链缓存),
|
||||
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网;
|
||||
其它平台在没有可用 OS 沙盒时会回退为不沙盒运行(越界问一次与 Linux 支持见
|
||||
`bash` 本身默认进 OS 沙盒(`[sandbox] bash`:macOS 使用 Seatbelt,Linux 使用 bubblewrap,
|
||||
原生 Windows 使用 native helper):命令只能写这些 root(外加平台按命令提供的临时/缓存 root),
|
||||
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网。
|
||||
原生 Windows helper 使用 Reasonix 内置的 Windows sandbox backend:
|
||||
只读命令使用 AppContainer,可写命令使用 low-integrity token;它会临时授予
|
||||
workspace、每次命令专用 temp root 和目标可执行文件的访问权,对 `forbid_read`
|
||||
(文件和目录皆可)临时添加 deny ACE,修改前记录被触碰目录的 DACL,命令结束后尽力恢复。
|
||||
作用于同一 workspace 的并发命令会被串行化,避免各自的 ACL 修改互相破坏;被强杀命令
|
||||
残留的 low-integrity 标签或 `forbid_read` deny ACE 会由下一次运行清理。由于可写命令跑在
|
||||
low-integrity token 下,除配置的 root 外它仍能写入 Windows 对任何 low-integrity 进程开放的
|
||||
少数位置(例如 `%USERPROFILE%\AppData\LocalLow`),但 workspace 边界与 `forbid_read`
|
||||
拒绝依然有效。只读 AppContainer 命令在关闭网络时不给 network capability;可写 Windows 命令遇到
|
||||
`[sandbox] network = false` 时会 fail closed。没有可用 OS 沙盒时,`bash = "enforce"` 会拒绝 bash 执行,不会无沙盒运行
|
||||
(越界询问与可选的 Windows elevated 加固见
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope))。
|
||||
|
||||
Windows 沙盒排障:沙盒会把 Reasonix 可执行文件自身以隐藏 helper 方式重新拉起,
|
||||
CLI 与桌面端都内置了这个 helper 入口——若某个构建缺少入口而又请求 enforce,
|
||||
bash 会以明确报错拒绝执行,而不是返回空输出。同一 workspace 上排队等待另一条
|
||||
沙盒命令时会打印一行“waiting for another sandboxed command”提示,并在可识别时
|
||||
标出持锁命令及其 PID。前台命令排队 1 分钟后即失败并给出同样的持锁信息(被挡住
|
||||
的回合应尽快报错,而不是挂住);后台任务最多等 10 分钟,`WINDOWS_SANDBOX_LOCK_MS`
|
||||
可同时覆盖两者。应先停止提示中点名的命令;调大等待上限只会让后续命令等更久。
|
||||
如果只有 Git-for-Windows/MSYS2 bash
|
||||
下的沙盒命令失败,可试 `[tools.shell] prefer = "powershell"`——MSYS 运行时在
|
||||
low-integrity token 下较脆弱。运行 `reasonix doctor` 可查看解析到的 shell、沙盒
|
||||
可用性,以及项目 `reasonix.toml` 是否固定了 `[sandbox]`(项目文件优先级高于
|
||||
Settings/用户配置;沙盒配置变更需 reload session config 或新开会话才生效)。
|
||||
|
||||
## 插件(MCP)
|
||||
|
||||
Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdio`(默认)启动本地子进
|
||||
@@ -529,6 +554,10 @@ Goal 是长期目标的统一运行机制。普通 `/goal` 继续走轻量 Goal
|
||||
Settings -> Skills 或斜杠菜单里。普通聊天输入如果命中很强的长周期信号,也会被 host 自动
|
||||
升级为等价的 `/goal --research <原输入>`。
|
||||
|
||||
复杂任务建议把目标写成[任务合约](./TASK_CONTRACT.zh-CN.md):Context、Request、
|
||||
Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当作自主执行的边界;
|
||||
除非下一步需要不可逆或对外可见操作、任务范围变化,或必须由用户提供信息,否则会继续采用合理默认值推进,并在最后汇报假设与结果。
|
||||
|
||||
AutoResearch 会在这些目标里自动启用:包含“持续”“长期”“彻底”“直到根因明确”“多轮排查”
|
||||
“不要原地打转”“完整方案”“跑实验”“反复验证”“系统性研究”等强信号;或者目标同时包含
|
||||
研究/排查、实现/修复、验证/测试、优化/文档/发布等多个阶段;或者用户明确给出
|
||||
|
||||
+9
-10
@@ -11,7 +11,7 @@ changed and how to move over.
|
||||
| Language | TypeScript / Node | Go |
|
||||
| Branch | [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) (maintenance only) | `main-v2` (default, active) |
|
||||
| Versions | `0.x` (up to v0.54.x) | `1.0.0`+ |
|
||||
| Install | `npm i -g reasonix` (the `latest` tag, stays on `0.x`) | `npm i -g reasonix@next` — `latest` deliberately stays on `0.x`; or a release archive / `go build` |
|
||||
| Install | `npm i -g reasonix@0.53.2` (pin a `0.x` version) | `npm i -g reasonix` — `latest` points at the current `1.x` stable; or a release archive / `go build` |
|
||||
| Code intelligence | embedding semantic search + tree-sitter symbols | LSP-assisted code reading plus grep/read_file/glob; semantic index is not yet ported |
|
||||
|
||||
"v1" and "v2" are **codebase generations**, not semver: the v1 line never reached
|
||||
@@ -23,19 +23,18 @@ changed and how to move over.
|
||||
same way esbuild/biome ship native binaries via npm). The binary itself is a
|
||||
standalone Go executable; npm is only the installer, not a runtime dependency.
|
||||
|
||||
**`npm i -g reasonix` deliberately still installs `0.x`.** A bare install — and
|
||||
`npx reasonix`, and 0.53's own `update` — follows npm's `latest` tag, which we
|
||||
keep pinned to the `0.x` line so existing users aren't pulled into the rewrite
|
||||
without asking. v1.x (Go) ships under the `next` tag; opt in explicitly:
|
||||
**`npm i -g reasonix` installs the current `1.x` stable.** npm's `latest` tag
|
||||
moved to the Go line with `1.17.5` — the earlier "`latest` stays pinned to
|
||||
`0.x`" migration guard silently downgraded `npm update -g` users once 1.x went
|
||||
stable (#5822), so it was retired. Release candidates still ship under the
|
||||
`next` tag; `0.x` stays installable by pinning:
|
||||
|
||||
```sh
|
||||
npm i -g reasonix@next # or pin a version: reasonix@1.1.0
|
||||
reasonix
|
||||
npm i -g reasonix # current 1.x stable
|
||||
npm i -g reasonix@next # release candidate, when one is ahead of stable
|
||||
npm i -g reasonix@0.53.2 # pin the legacy TS build
|
||||
```
|
||||
|
||||
`latest` will stay on `0.x` for the foreseeable future, so installing or
|
||||
updating v2 always means `@next` (or a pinned `1.x`).
|
||||
|
||||
Prebuilt archives (`reasonix-<os>-<arch>.tar.gz` / `.zip`) and the desktop
|
||||
installer are attached to each GitHub release. These are a **separate channel**
|
||||
from npm: the installer drops a standalone desktop/binary build and does not
|
||||
|
||||
+10
-7
@@ -17,7 +17,7 @@ provides the pre-release buffer instead of a long-lived branch.
|
||||
|
||||
| Surface | Stable | Pre-release buffer |
|
||||
|---|---|---|
|
||||
| npm | `latest` (0.x), `next` (1.x) | `canary` (`npm i reasonix@canary`) |
|
||||
| npm | `latest` (current 1.x stable) | `next` (rc), `canary` (`npm i reasonix@canary`) |
|
||||
| Desktop | R2 `latest/` pointer + release gateway | R2 `canary/` pointer + release gateway proxy (never on the GitHub releases page) |
|
||||
|
||||
A canary build is isolated: it **never** moves `latest` / `next` / desktop `latest/`.
|
||||
@@ -53,15 +53,18 @@ the `release` environment deployment.
|
||||
5. **Ship stable** when the canary is clean — push the three tags:
|
||||
```sh
|
||||
git tag v1.4.0 && git push origin v1.4.0 # CLI binaries + Homebrew
|
||||
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> next
|
||||
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> latest
|
||||
git tag desktop-v1.4.0 && git push origin desktop-v1.4.0 # desktop -> R2 latest/
|
||||
```
|
||||
Each stable run **waits for esengine to approve the `release` environment** before publishing.
|
||||
6. **Promote to default install** (optional, when 1.x should become the bare `npm i` target):
|
||||
```sh
|
||||
npm dist-tag add reasonix@1.4.0 latest
|
||||
```
|
||||
7. **Next cycle** — the canary rolls on toward `1.5.0`.
|
||||
A stable `npm-v*` publish moves the `latest` dist-tag automatically (build.mjs)
|
||||
and release-npm.yml verifies it landed. **Do not skip the npm tag**: the stable
|
||||
CLI release (release.yml) fails when the matching `npm-v*` tag was never pushed
|
||||
— that guard exists because 1.0.0–1.17.5 shipped without stable npm tags and
|
||||
`npm update -g` silently downgraded users to 0.53.2 (#5822). A pushed tag whose
|
||||
publish is still awaiting approval only warns; release-npm.yml's verify step
|
||||
owns asserting the dist-tag lands.
|
||||
6. **Next cycle** — the canary rolls on toward `1.5.0`.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
+39
-8
@@ -362,7 +362,17 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
model reports completion, repeats the same blocked state three times, the user
|
||||
stops it, or the safety continuation limit is reached. Blocked-state matching
|
||||
is normalized for casing, whitespace, and punctuation so minor wording drift
|
||||
does not reset the audit; restarting a goal begins a fresh blocked audit.
|
||||
does not reset the audit; restarting a goal begins a fresh blocked audit. A
|
||||
goal is treated as a task contract: if the objective includes Context,
|
||||
Request, Output format, Constraints, or Pause policy sections, those sections
|
||||
define the autonomous work boundary. When they are absent, the model infers a
|
||||
lightweight contract from the conversation and workspace. The injected goal
|
||||
block tells the model to pause only for irreversible or externally visible
|
||||
operations, scope changes, or information only the user can provide; ordinary
|
||||
uncertainty should be handled with sensible defaults and reported as an
|
||||
assumption. Completion requires the concrete request, output format,
|
||||
constraints, and relevant verification expectations to be satisfied or
|
||||
explicitly reported as unverified.
|
||||
Goals that look like long-horizon research, debugging, optimization, or
|
||||
implementation work automatically add an AutoResearch protocol to the same
|
||||
transient active-goal user block. AutoResearch is a Goal strategy, not a
|
||||
@@ -646,9 +656,29 @@ not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansio
|
||||
and should be absolute, or use `${HOME}` for home-relative secrets such as
|
||||
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
|
||||
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS, bubblewrap on
|
||||
Linux): each command is allowed to write only the same roots (+ temp and
|
||||
toolchain caches), denied reads under `forbid_read`, and allowed to reach the
|
||||
network only when `network = true`.
|
||||
Linux, and a native helper on Windows): each command is allowed to write only
|
||||
the same roots plus platform-specific command temp/cache roots, denied reads
|
||||
under `forbid_read`, and allowed to reach the network only when
|
||||
`network = true`.
|
||||
The native Windows helper uses Reasonix's bundled Windows sandbox backend:
|
||||
AppContainer for read-only commands and a low-integrity token for writable
|
||||
commands, with temporary ACL grants for
|
||||
writable roots and tool executables, a per-command temp root instead of mutating
|
||||
the global Temp directory, temporary deny ACEs for `forbid_read` (files and
|
||||
directories), best-effort restoration from pre-run DACL snapshots for touched
|
||||
directories, and a kill-on-close Job Object. Because the sandbox works by
|
||||
temporarily mutating shared-path ACLs and integrity labels, concurrent commands
|
||||
against the same root are serialized with a per-root lock, and residue from a
|
||||
force-killed command (a lingering low-integrity label or `forbid_read` deny ACE)
|
||||
is swept by the next run so a crash cannot durably lower a workspace's integrity
|
||||
or lock the user out of a `forbid_read` path. A writable command runs under a
|
||||
low-integrity token, so beyond the configured roots it retains write access to
|
||||
the narrow set of locations Windows leaves writable to any low-integrity process
|
||||
(e.g. `AppData\LocalLow`); the workspace boundary and `forbid_read` denials are
|
||||
unaffected. Read-only AppContainer commands omit network capabilities when
|
||||
networking is disabled; writable Windows commands fail closed when
|
||||
`network = false` because the low-integrity token does not provide a reliable
|
||||
per-process network block without elevated firewall/WFP setup.
|
||||
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
|
||||
instead of running unconfined. Install the platform sandbox backend
|
||||
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
|
||||
@@ -681,14 +711,15 @@ behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9)
|
||||
|
||||
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
|
||||
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
|
||||
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
|
||||
macOS, bubblewrap on Linux, and a native Windows helper ship, on by default
|
||||
when available** (see §5).
|
||||
Remaining: (a)
|
||||
the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
|
||||
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
|
||||
command just fails and the model adapts), which completes the "allow inside the
|
||||
box, prompt at its edge" model; (b) broader OS sandbox support beyond current
|
||||
Seatbelt/bubblewrap hosts. Shells out to OS tooling so the binary stays
|
||||
dependency-free. With this in
|
||||
box, prompt at its edge" model; (b) an optional elevated Windows backend with a
|
||||
dedicated sandbox user for enterprise hardening. Shells out to OS tooling so
|
||||
the binary stays dependency-free. With this in
|
||||
place, "always allow" rule persistence becomes optional rather than load-bearing.
|
||||
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
|
||||
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Task Contracts and Pause Policy
|
||||
|
||||
<a href="./GUIDE.md">Guide</a>
|
||||
·
|
||||
<a href="./TASK_CONTRACT.zh-CN.md">Simplified Chinese</a>
|
||||
|
||||
Reasonix works best when nontrivial work is described as a **task contract**:
|
||||
what the work is for, what action is requested, how the result should be
|
||||
delivered, what boundaries must be preserved, and when the agent should pause.
|
||||
Some prompt templates call the last section "Checkpoint"; Reasonix documents it
|
||||
as "Pause policy" to avoid confusion with Checkpoints/Rewind snapshots.
|
||||
|
||||
This is intentionally not a larger role prompt. Strong coding agents usually do
|
||||
not need step-by-step thinking instructions. They need clear boundaries and
|
||||
acceptance criteria.
|
||||
|
||||
## Template
|
||||
|
||||
```text
|
||||
Context:
|
||||
I am working on [larger task].
|
||||
The target audience/user is [who].
|
||||
This result should help them [achieve what outcome].
|
||||
|
||||
Request:
|
||||
Please complete [one clear action].
|
||||
|
||||
Output format:
|
||||
Return the result as [specific structure].
|
||||
It must include [required sections].
|
||||
Keep it within [length or scope].
|
||||
|
||||
Constraints:
|
||||
Do not [bad assumption].
|
||||
Do not [out-of-scope content].
|
||||
Do not [low-quality output shape].
|
||||
If information is missing, mark uncertainty explicitly.
|
||||
|
||||
Pause policy:
|
||||
Unless the next step involves an irreversible or externally visible operation,
|
||||
a scope change, or information only I can provide, keep working and report back
|
||||
after the task is complete.
|
||||
```
|
||||
|
||||
## How Reasonix Uses It
|
||||
|
||||
- **Normal chat** can use the template directly for one-off work.
|
||||
- **Goal mode** treats the goal as a task contract and keeps working until the
|
||||
request, output format, constraints, and verification expectations are
|
||||
satisfied.
|
||||
- **Plan mode** is still the right choice when you want a read-only plan before
|
||||
edits or side effects.
|
||||
- **Tool approval** remains separate: file writes, shell commands, publishing,
|
||||
credentials, and external effects still follow the configured approval policy.
|
||||
- **Checkpoints/Rewind** are file and conversation snapshots. The task contract's
|
||||
pause policy is about when the agent should ask the user before continuing.
|
||||
|
||||
The Goal-mode task contract rides the provider-visible user turn. It does not
|
||||
rewrite the cache-stable system prompt, memory prefix, or tool schemas.
|
||||
|
||||
## Example
|
||||
|
||||
```text
|
||||
/goal Context:
|
||||
I am improving the desktop composer.
|
||||
The target user is someone doing repeated code review sessions.
|
||||
This should help them avoid accidental interruptions.
|
||||
|
||||
Request:
|
||||
Make the slash-command menu keep keyboard focus while suggestions are open.
|
||||
|
||||
Output format:
|
||||
After implementation, summarize changed files and verification results.
|
||||
|
||||
Constraints:
|
||||
Do not change the Wails JSON contract.
|
||||
Do not refactor unrelated composer state.
|
||||
If browser verification cannot run, say why.
|
||||
|
||||
Pause policy:
|
||||
Unless the next step requires a product decision, a public push, or credentials,
|
||||
continue through implementation and verification before reporting back.
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# 任务合约与暂停策略
|
||||
|
||||
<a href="./GUIDE.zh-CN.md">使用指南</a>
|
||||
·
|
||||
<a href="./TASK_CONTRACT.md">English</a>
|
||||
|
||||
复杂任务最适合写成一份**任务合约**:背景是什么、要完成什么动作、结果怎么交付、哪些边界不能越过,以及什么时候才需要暂停问用户。
|
||||
有些 prompt 模板把最后一段叫 “Checkpoint”;Reasonix 文档里叫 “Pause policy”,避免和 Checkpoints/Rewind 快照混淆。
|
||||
|
||||
这不是更长的角色设定。更强的 coding agent 通常不需要用户教它一步步思考;它更需要清楚的任务边界和验收标准。
|
||||
|
||||
## 模板
|
||||
|
||||
```text
|
||||
Context:
|
||||
我正在做 [大任务]。
|
||||
目标对象是 [谁]。
|
||||
这个结果要帮助他们 [达成什么结果]。
|
||||
|
||||
Request:
|
||||
请完成 [一个明确动作]。
|
||||
|
||||
Output format:
|
||||
请按照 [具体结构] 输出。
|
||||
必须包含 [必要模块]。
|
||||
不要超过 [长度/范围]。
|
||||
|
||||
Constraints:
|
||||
不要 [错误假设]。
|
||||
不要 [越界内容]。
|
||||
不要 [低质量输出形式]。
|
||||
如果 [信息不足],请明确标注不确定性。
|
||||
|
||||
Pause policy:
|
||||
除非下一步涉及不可逆或对外可见操作、任务范围变化,或需要我提供信息,否则请继续完成任务后再汇报。
|
||||
```
|
||||
|
||||
## Reasonix 如何使用它
|
||||
|
||||
- **普通聊天**可以直接粘贴这份模板,适合一次性任务。
|
||||
- **Goal 模式**会把目标当作任务合约持续推进,直到 request、output format、constraints 和必要验证都满足。
|
||||
- **计划模式**仍然适合“先只读分析、确认方案后再改”的场景。
|
||||
- **工具审批**仍然独立生效:写文件、跑 shell、发布、凭证、外部副作用都会继续遵守配置的审批策略。
|
||||
- **Checkpoints/Rewind** 是代码和会话快照;这里的暂停策略只描述 agent 什么时候应该停下来问用户。
|
||||
|
||||
Goal 模式里的任务合约会随 user turn 注入 provider 可见上下文,不会改写 cache-stable system prompt、memory prefix 或 tool schema。
|
||||
|
||||
## 示例
|
||||
|
||||
```text
|
||||
/goal Context:
|
||||
我正在改进桌面端输入框。
|
||||
目标对象是需要连续做代码审查的用户。
|
||||
这个结果要帮助他们避免被补全菜单打断输入。
|
||||
|
||||
Request:
|
||||
修复 slash-command 菜单打开时键盘焦点丢失的问题。
|
||||
|
||||
Output format:
|
||||
完成后按“改了什么 / 验证结果 / 剩余风险”汇报。
|
||||
|
||||
Constraints:
|
||||
不要改变 Wails JSON 合约。
|
||||
不要顺手重构无关的 composer 状态。
|
||||
如果无法跑浏览器验证,请说明原因。
|
||||
|
||||
Pause policy:
|
||||
除非下一步需要产品判断、公开 push 或凭证,否则继续完成实现和验证后再汇报。
|
||||
```
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/provider"
|
||||
@@ -330,11 +331,27 @@ func (s *updateSink) requestAskQuestion(ctx context.Context, askID string, q eve
|
||||
}
|
||||
|
||||
func approvalSessionOptionName(tool, subject string) string {
|
||||
if tool == control.SandboxEscapeApprovalTool {
|
||||
return "Use real environment for this session"
|
||||
}
|
||||
sessionRule := permission.SessionGrantRuleForScope(tool, subject)
|
||||
return "Allow " + sessionRule + " for this session"
|
||||
}
|
||||
|
||||
func approvalOptions(tool, subject string) []PermissionOption {
|
||||
if control.RequiresFreshHumanApprovalTool(tool) {
|
||||
if tool == control.SandboxEscapeApprovalTool {
|
||||
return []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
{OptionID: string(OptAllowAlways), Name: approvalSessionOptionName(tool, subject), Kind: OptAllowAlways},
|
||||
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
|
||||
}
|
||||
}
|
||||
return []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
|
||||
}
|
||||
}
|
||||
allowSessionName := approvalSessionOptionName(tool, subject)
|
||||
options := []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
@@ -383,6 +400,8 @@ func toolKindFor(name string) string {
|
||||
return "edit"
|
||||
case "bash":
|
||||
return "execute"
|
||||
case control.SandboxEscapeApprovalTool:
|
||||
return "execute"
|
||||
}
|
||||
n := strings.ToLower(name)
|
||||
switch {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
@@ -306,6 +307,56 @@ func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSinkSandboxEscapeApprovalOffersSessionGrant(t *testing.T) {
|
||||
fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(params)
|
||||
var p PermissionRequestParams
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatalf("permission params: %v", err)
|
||||
}
|
||||
assertACPv1PermissionOptionKinds(t, p.Options)
|
||||
var hasOnce, hasSession, hasReject bool
|
||||
for _, opt := range p.Options {
|
||||
switch opt.OptionID {
|
||||
case string(OptAllowOnce):
|
||||
hasOnce = opt.Kind == OptAllowOnce
|
||||
case string(OptAllowAlways):
|
||||
hasSession = opt.Kind == OptAllowAlways && opt.Name == "Use real environment for this session"
|
||||
case string(OptRejectOnce):
|
||||
hasReject = opt.Kind == OptRejectOnce
|
||||
default:
|
||||
t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
|
||||
}
|
||||
}
|
||||
if len(p.Options) != 3 || !hasOnce || !hasSession || !hasReject {
|
||||
t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
|
||||
}
|
||||
res, _ := json.Marshal(PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
|
||||
})
|
||||
return res, nil
|
||||
}}
|
||||
sink := newUpdateSink(fn, "sess-1")
|
||||
got := make(chan approveCall, 1)
|
||||
sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
|
||||
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
|
||||
ID: "11",
|
||||
Tool: control.SandboxEscapeApprovalTool,
|
||||
Subject: "run unconfined once: go test ./...",
|
||||
}})
|
||||
|
||||
select {
|
||||
case c := <-got:
|
||||
want := approveCall{id: "11", allow: true, session: true, persist: false}
|
||||
if c != want {
|
||||
t.Errorf("approve = %+v, want %+v", c, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("approve was never called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSinkApprovalDenied(t *testing.T) {
|
||||
// Both a "cancelled" outcome and a transport error must deny the call.
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -98,7 +98,7 @@ func (f *configurableFactory) NewSession(_ context.Context, p SessionParams) (*c
|
||||
return behavior(ctx, sink, input, p)
|
||||
},
|
||||
}
|
||||
opts := control.Options{Runner: runner, Sink: p.Sink, SessionDir: f.dir}
|
||||
opts := control.Options{Runner: runner, Sink: p.Sink, SessionDir: f.dir, OnSessionRecovered: p.OnSessionRecovered}
|
||||
if f.withHooks {
|
||||
opts.Hooks = f.hookRunner()
|
||||
}
|
||||
|
||||
+244
-27
@@ -35,13 +35,16 @@ import (
|
||||
// Cwd roots the session's file tools and bash (built via builtin.Workspace).
|
||||
// Model and EffortOverride are optional session-local provider selectors from
|
||||
// ACP config options. MCPServers are the MCP servers the client asked the agent
|
||||
// to connect for this session.
|
||||
// to connect for this session. OnSessionRecovered is the service's bookkeeping
|
||||
// hook for automatic transcript recovery branches (see sessionRecoveredHandler);
|
||||
// factories must wire it into the controller they build.
|
||||
type SessionParams struct {
|
||||
Cwd string
|
||||
MCPServers []plugin.Spec
|
||||
Sink event.Sink
|
||||
Model string
|
||||
EffortOverride *string
|
||||
Cwd string
|
||||
MCPServers []plugin.Spec
|
||||
Sink event.Sink
|
||||
Model string
|
||||
EffortOverride *string
|
||||
OnSessionRecovered func(control.SessionRecoveryInfo) error
|
||||
}
|
||||
|
||||
// Factory builds the per-session controller. The composition root (the cli's
|
||||
@@ -168,6 +171,13 @@ type acpSession struct {
|
||||
done chan struct{}
|
||||
running bool
|
||||
deleted bool
|
||||
// lease is the session lease guarding transcript against other runtimes
|
||||
// (a desktop window, the CLI) for the life of this session. Held from
|
||||
// session/new / session/load and released on close/delete/teardown.
|
||||
// Config rebuilds keep the same transcript; when a snapshot conflict
|
||||
// retargets the controller to a recovery branch, sessionRecoveredHandler
|
||||
// moves transcript and this lease to the recovery file at commit time.
|
||||
lease *agent.SessionLease
|
||||
// maintenanceDone is non-nil while session-owned maintenance, such as an
|
||||
// idle config rebuild, is in flight outside mu.
|
||||
maintenanceDone chan struct{}
|
||||
@@ -273,6 +283,104 @@ func (s *acpSession) currentCtrl() acpController {
|
||||
return s.ctrl
|
||||
}
|
||||
|
||||
// releaseSessionLease drops the session's transcript lease, if any. Idempotent.
|
||||
func (s *acpSession) releaseSessionLease() {
|
||||
s.mu.Lock()
|
||||
lease := s.lease
|
||||
s.lease = nil
|
||||
s.mu.Unlock()
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// sessionLeaseBindError maps a lease-acquisition failure to the protocol
|
||||
// error the client sees: a held session names its holder with the shared CLI
|
||||
// wording; anything else is an internal error.
|
||||
func sessionLeaseBindError(method string, err error) *RPCError {
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return &RPCError{
|
||||
Code: ErrInvalidRequest,
|
||||
Message: method + ": " + control.SessionInUseMessage(err) + "; " + control.SessionLeaseCloseHint,
|
||||
}
|
||||
}
|
||||
return &RPCError{Code: ErrInternal, Message: method + ": session lease: " + err.Error()}
|
||||
}
|
||||
|
||||
// sessionRecoveredHandler returns the OnSessionRecovered callback wired into
|
||||
// every controller built for session id. When a snapshot conflict retargets
|
||||
// the controller to a recovery branch (turn-end autosave in persistAfterTurn,
|
||||
// or the pre-rebuild snapshot in rebuildSession), the ACP bookkeeping must
|
||||
// follow at commit time: session/prompt reports sess.transcript,
|
||||
// session/delete destroys it, and the session lease must guard the file the
|
||||
// controller actually writes. The recovery lease is acquired before the old
|
||||
// one is released so the outgoing transcript stays guarded until the new one
|
||||
// is secured; a failure aborts the recovery commit and the controller stays
|
||||
// on the original path (the next save retries).
|
||||
func (s *service) sessionRecoveredHandler(id string) func(control.SessionRecoveryInfo) error {
|
||||
return func(info control.SessionRecoveryInfo) error {
|
||||
recoveryPath := strings.TrimSpace(info.RecoveryPath)
|
||||
if recoveryPath == "" {
|
||||
return nil
|
||||
}
|
||||
sess := s.session(id)
|
||||
if sess == nil {
|
||||
return nil
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recoveryPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return fmt.Errorf("bind recovery session: %s; %s",
|
||||
control.SessionInUseMessage(err), control.SessionLeaseCloseHint)
|
||||
}
|
||||
return fmt.Errorf("bind recovery session: %w", err)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
if sess.deleted {
|
||||
sess.mu.Unlock()
|
||||
lease.Release()
|
||||
return fmt.Errorf("bind recovery session: session is deleted")
|
||||
}
|
||||
old := sess.lease
|
||||
sess.lease = lease
|
||||
sess.transcript = recoveryPath
|
||||
meta := sess.metaLocked()
|
||||
sess.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Release()
|
||||
}
|
||||
_ = saveACPMeta(recoveryPath, meta)
|
||||
// Leave a redirect on the id-keyed sidecar so restart-time lookups
|
||||
// (session/load, session/resume, session/delete, loadMeta) resolve the
|
||||
// id to the recovery file; without it the next process reopens the
|
||||
// pre-recovery transcript. Always written against the id-keyed path,
|
||||
// so resolution stays a single hop even for recovery-of-recovery.
|
||||
if dir := s.sessionDir(); dir != "" {
|
||||
if idPath := transcriptPath(dir, id); idPath != recoveryPath {
|
||||
idMeta, _, err := loadACPMeta(idPath)
|
||||
if err != nil {
|
||||
slog.Warn("acp: load id-keyed meta for recovery redirect", "err", err)
|
||||
idMeta = acpSessionMeta{}
|
||||
}
|
||||
if idMeta.SessionID == "" {
|
||||
idMeta.SessionID = id
|
||||
}
|
||||
if idMeta.Cwd == "" {
|
||||
idMeta.Cwd = meta.Cwd
|
||||
}
|
||||
if idMeta.CreatedAt.IsZero() {
|
||||
idMeta.CreatedAt = meta.CreatedAt
|
||||
}
|
||||
idMeta.ActiveTranscript = filepath.Base(recoveryPath)
|
||||
if err := saveACPMeta(idPath, idMeta); err != nil {
|
||||
slog.Warn("acp: save recovery redirect", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// initialize advertises the agent's capability set: persisted load plus ACP v1
|
||||
// list/resume/close/delete lifecycle helpers, prompts carrying inline resource
|
||||
// text (embeddedContext) but not image/audio, and stdio / Streamable HTTP MCP
|
||||
@@ -352,11 +460,12 @@ func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, err
|
||||
|
||||
sink := newUpdateSink(s.conn, id)
|
||||
ctrl, err := s.factory.NewSession(ctx, SessionParams{
|
||||
Cwd: cwd,
|
||||
MCPServers: mcpServers,
|
||||
Sink: sink,
|
||||
Model: cfgState.Model,
|
||||
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
||||
Cwd: cwd,
|
||||
MCPServers: mcpServers,
|
||||
Sink: sink,
|
||||
Model: cfgState.Model,
|
||||
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
||||
OnSessionRecovered: s.sessionRecoveredHandler(id),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
|
||||
@@ -379,9 +488,17 @@ func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, err
|
||||
}
|
||||
// Pin a transcript file keyed by session id when the controller has a session
|
||||
// dir, so every turn auto-saves there, session/prompt can hand the path back,
|
||||
// and session/load can find it again by id across process restarts.
|
||||
// and session/load can find it again by id across process restarts. The
|
||||
// session lease is taken with it (defensive: the id-keyed path is brand new)
|
||||
// so no other runtime can bind the transcript while this session lives.
|
||||
if dir := ctrl.SessionDir(); dir != "" {
|
||||
sess.transcript = transcriptPath(dir, id)
|
||||
lease, err := agent.TryAcquireSessionLease(sess.transcript)
|
||||
if err != nil {
|
||||
ctrl.Close()
|
||||
return nil, sessionLeaseBindError("session/new", err)
|
||||
}
|
||||
sess.lease = lease
|
||||
ctrl.SetSessionPath(sess.transcript)
|
||||
}
|
||||
|
||||
@@ -459,7 +576,7 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
|
||||
var saved acpSessionMeta
|
||||
persistedPath := ""
|
||||
if dir := s.sessionDir(); dir != "" {
|
||||
persistedPath = transcriptPath(dir, id)
|
||||
persistedPath = resolveTranscriptPath(dir, id)
|
||||
if agent.IsCleanupPending(persistedPath) {
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
||||
}
|
||||
@@ -484,11 +601,12 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
|
||||
|
||||
sink := newUpdateSink(s.conn, id)
|
||||
ctrl, err := s.factory.NewSession(ctx, SessionParams{
|
||||
Cwd: cwd,
|
||||
MCPServers: mcpServers,
|
||||
Sink: sink,
|
||||
Model: cfgState.Model,
|
||||
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
||||
Cwd: cwd,
|
||||
MCPServers: mcpServers,
|
||||
Sink: sink,
|
||||
Model: cfgState.Model,
|
||||
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
||||
OnSessionRecovered: s.sessionRecoveredHandler(id),
|
||||
})
|
||||
if err != nil {
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
||||
@@ -502,13 +620,22 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
|
||||
ctrl.Close()
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": persistence is disabled"}
|
||||
}
|
||||
path := transcriptPath(dir, id)
|
||||
path := resolveTranscriptPath(dir, id)
|
||||
if path != persistedPath && agent.IsCleanupPending(path) {
|
||||
ctrl.Close()
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
||||
}
|
||||
// Bind the transcript for writing only if no other runtime (a desktop
|
||||
// window, the CLI) holds it; the editor should not silently double-write a
|
||||
// session that is open elsewhere.
|
||||
lease, leaseErr := agent.TryAcquireSessionLease(path)
|
||||
if leaseErr != nil {
|
||||
ctrl.Close()
|
||||
return SessionConfigState{}, sessionLeaseBindError(method, leaseErr)
|
||||
}
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
lease.Release()
|
||||
ctrl.Close()
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
||||
}
|
||||
@@ -529,8 +656,10 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
|
||||
title: meta.Title,
|
||||
createdAt: meta.CreatedAt,
|
||||
updatedAt: meta.UpdatedAt,
|
||||
lease: lease,
|
||||
}
|
||||
if err := saveACPMeta(path, sess.meta()); err != nil {
|
||||
sess.releaseSessionLease()
|
||||
ctrl.Close()
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
||||
}
|
||||
@@ -552,6 +681,38 @@ func transcriptPath(dir, id string) string {
|
||||
return filepath.Join(dir, id+".jsonl")
|
||||
}
|
||||
|
||||
// resolveTranscriptPath returns the transcript file session id currently
|
||||
// lives in. That is the id-keyed path by default; after a snapshot recovery
|
||||
// moved the live session onto a recovery branch, the id-keyed sidecar carries
|
||||
// an ActiveTranscript redirect (written by sessionRecoveredHandler) that
|
||||
// load/resume/delete/meta lookups must follow, or a restart silently reopens
|
||||
// the pre-recovery transcript. The redirect is a basename, must stay inside
|
||||
// dir, and its target must exist and claim the same session id; anything else
|
||||
// falls back to the id-keyed path.
|
||||
func resolveTranscriptPath(dir, id string) string {
|
||||
path := transcriptPath(dir, id)
|
||||
meta, ok, err := loadACPMeta(path)
|
||||
if err != nil || !ok {
|
||||
return path
|
||||
}
|
||||
active := strings.TrimSpace(meta.ActiveTranscript)
|
||||
if active == "" || active == filepath.Base(path) {
|
||||
return path
|
||||
}
|
||||
if filepath.Base(active) != active {
|
||||
return path
|
||||
}
|
||||
resolved := filepath.Join(dir, active)
|
||||
if !sessionFileExists(resolved) {
|
||||
return path
|
||||
}
|
||||
targetMeta, ok, err := loadACPMeta(resolved)
|
||||
if err != nil || !ok || targetMeta.SessionID != id {
|
||||
return path
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// sessionPrompt runs one turn. It flattens the prompt blocks to text and runs the
|
||||
// session's controller synchronously under a per-turn cancelable context (so
|
||||
// session/cancel can stop it), then reports why the turn ended. The controller
|
||||
@@ -718,7 +879,6 @@ func (s *service) rebuildSession(ctx context.Context, sess *acpSession, cfgState
|
||||
sess.pendingConfig = nil
|
||||
|
||||
cur := sess.ctrl
|
||||
prevPath := cur.SessionPath()
|
||||
sink := sess.sink
|
||||
mcpServers := clonePluginSpecs(sess.mcpServers)
|
||||
cwd := sess.cwd
|
||||
@@ -735,14 +895,24 @@ func (s *service) rebuildSession(ctx context.Context, sess *acpSession, cfgState
|
||||
if err := cur.Snapshot(); err != nil {
|
||||
return &RPCError{Code: ErrInternal, Message: "session config: snapshot before switch: " + err.Error()}
|
||||
}
|
||||
// Capture the adopt path and history only after Snapshot: a snapshot
|
||||
// conflict can retarget cur to a recovery branch (or adopt the newer disk
|
||||
// transcript), and a pre-snapshot capture would bind the rebuilt controller
|
||||
// back to the original file, re-conflicting on every later save. When that
|
||||
// recovery fired, sessionRecoveredHandler already moved sess.transcript
|
||||
// and the session lease to the recovery file, so prevPath, the session
|
||||
// bookkeeping, and the controller agree on one path here.
|
||||
// SessionPath is controller-locked, so reading it off sess.mu is safe.
|
||||
prevPath := cur.SessionPath()
|
||||
carried := cur.History()
|
||||
|
||||
newCtrl, err := s.factory.NewSession(ctx, SessionParams{
|
||||
Cwd: cwd,
|
||||
MCPServers: mcpServers,
|
||||
Sink: sink,
|
||||
Model: cfgState.Model,
|
||||
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
||||
Cwd: cwd,
|
||||
MCPServers: mcpServers,
|
||||
Sink: sink,
|
||||
Model: cfgState.Model,
|
||||
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
||||
OnSessionRecovered: s.sessionRecoveredHandler(sess.id),
|
||||
})
|
||||
if err != nil {
|
||||
return &RPCError{Code: ErrInternal, Message: "session config: " + err.Error()}
|
||||
@@ -856,6 +1026,7 @@ func (s *service) sessionClose(_ context.Context, raw json.RawMessage) (any, err
|
||||
if sess := s.takeSession(p.SessionID); sess != nil {
|
||||
sess.abortAndWait()
|
||||
sess.ctrl.Close()
|
||||
sess.releaseSessionLease()
|
||||
}
|
||||
return SessionCloseResult{}, nil
|
||||
}
|
||||
@@ -883,7 +1054,18 @@ func (s *service) sessionList(_ context.Context, raw json.RawMessage) (any, erro
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/list: " + err.Error()}
|
||||
}
|
||||
// A recovered session has two sidecars claiming the same id: the
|
||||
// active recovery transcript's own meta and the id-keyed redirect.
|
||||
// Reduce to one representative per id before filtering, so the entry
|
||||
// shown never carries the stale pre-recovery title/timestamps.
|
||||
best := map[string]acpSessionMeta{}
|
||||
for _, meta := range metas {
|
||||
cur, ok := best[meta.SessionID]
|
||||
if !ok || listMetaBeats(meta, cur) {
|
||||
best[meta.SessionID] = meta
|
||||
}
|
||||
}
|
||||
for _, meta := range best {
|
||||
info := meta.info(nil)
|
||||
if sessionInfoMatchesCwd(info, filterCwd) {
|
||||
byID[info.SessionID] = info
|
||||
@@ -928,6 +1110,10 @@ func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, er
|
||||
var delayed bool
|
||||
if sess := s.takeSession(p.SessionID); sess != nil {
|
||||
sess.deleteAndWait()
|
||||
// The session is going away; drop its lease before removing files so
|
||||
// the lease sidecars retire with the release (they are not in
|
||||
// SessionSidecarFiles and would otherwise linger).
|
||||
sess.releaseSessionLease()
|
||||
path = sess.transcript
|
||||
destroy = sess.ctrl.BeginDestroySession(path)
|
||||
if result := destroy.Wait(); result.HasTimedOut() {
|
||||
@@ -943,7 +1129,7 @@ func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, er
|
||||
}
|
||||
if path == "" {
|
||||
if dir := s.sessionDir(); dir != "" {
|
||||
path = transcriptPath(dir, p.SessionID)
|
||||
path = resolveTranscriptPath(dir, p.SessionID)
|
||||
}
|
||||
}
|
||||
if path != "" && !delayed {
|
||||
@@ -954,6 +1140,17 @@ func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, er
|
||||
destroy.Finish()
|
||||
}
|
||||
}
|
||||
// A recovered session lives in two files: the recovery transcript (deleted
|
||||
// above) and the id-keyed original holding the redirect. Remove the twin
|
||||
// too, or it resurfaces in session/list as a ghost that delete-by-id can
|
||||
// never reach again.
|
||||
if dir := s.sessionDir(); dir != "" {
|
||||
if idPath := transcriptPath(dir, p.SessionID); idPath != path {
|
||||
if err := deleteSessionFiles(idPath); err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
|
||||
}
|
||||
}
|
||||
}
|
||||
return SessionDeleteResult{}, nil
|
||||
}
|
||||
|
||||
@@ -1134,7 +1331,7 @@ func (s *service) loadMeta(id string) (acpSessionMeta, bool) {
|
||||
if dir == "" {
|
||||
return acpSessionMeta{}, false
|
||||
}
|
||||
meta, ok, err := loadACPMeta(transcriptPath(dir, id))
|
||||
meta, ok, err := loadACPMeta(resolveTranscriptPath(dir, id))
|
||||
if err != nil {
|
||||
return acpSessionMeta{}, false
|
||||
}
|
||||
@@ -1151,6 +1348,7 @@ func (s *service) closeAll() {
|
||||
for _, sess := range sessions {
|
||||
sess.abortAndWait()
|
||||
sess.currentCtrl().Close()
|
||||
sess.releaseSessionLease()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,6 +1521,12 @@ type acpSessionMeta struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// ActiveTranscript, when set on the id-keyed sidecar, is the basename of
|
||||
// the transcript this session currently lives in: a snapshot recovery
|
||||
// moved the live session onto a recovery branch and left this redirect
|
||||
// behind so restart-time lookups (resolveTranscriptPath) follow the
|
||||
// session instead of reopening the pre-recovery file.
|
||||
ActiveTranscript string `json:"activeTranscript,omitempty"`
|
||||
}
|
||||
|
||||
func (m acpSessionMeta) info(extra map[string]any) SessionInfo {
|
||||
@@ -1488,6 +1692,19 @@ func sessionIDFromTranscript(path string) string {
|
||||
return base
|
||||
}
|
||||
|
||||
// listMetaBeats reports whether a should represent its session id in
|
||||
// session/list over b. A meta without an ActiveTranscript redirect is the
|
||||
// session's live transcript and always beats a redirect sidecar; between two
|
||||
// of the same kind the later UpdatedAt wins.
|
||||
func listMetaBeats(a, b acpSessionMeta) bool {
|
||||
aRedirect := strings.TrimSpace(a.ActiveTranscript) != ""
|
||||
bRedirect := strings.TrimSpace(b.ActiveTranscript) != ""
|
||||
if aRedirect != bRedirect {
|
||||
return !aRedirect
|
||||
}
|
||||
return a.UpdatedAt.After(b.UpdatedAt)
|
||||
}
|
||||
|
||||
func sessionInfoMatchesCwd(info SessionInfo, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func leaseTestFactory(dir string) *e2eFactory {
|
||||
return &e2eFactory{
|
||||
prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
||||
{{Type: provider.ChunkText, Text: "unused"}, {Type: provider.ChunkDone}},
|
||||
}},
|
||||
tool: fakeTool{name: "peek", ro: true, out: "unused"},
|
||||
policy: permission.New("ask", nil, nil, nil),
|
||||
sessionDir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLoadRefusedWhenLeaseHeld proves session/load returns a protocol
|
||||
// error naming the holder when the transcript is owned by another runtime
|
||||
// (a desktop window or CLI writing the same session), and does not register
|
||||
// the session.
|
||||
func TestSessionLoadRefusedWhenLeaseHeld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const id = "sess-held-lease"
|
||||
path := transcriptPath(dir, id)
|
||||
s := agent.NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "editor session"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holder, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("test holder acquire: %v", err)
|
||||
}
|
||||
defer holder.Release()
|
||||
|
||||
client, stop := startServer(t, leaseTestFactory(dir))
|
||||
defer stop()
|
||||
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
||||
|
||||
resp := client.call(t, "session/load", SessionLoadParams{SessionID: id, Cwd: t.TempDir()})
|
||||
if resp.Error == nil {
|
||||
t.Fatalf("session/load of a held session succeeded, want protocol error")
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "in use by another Reasonix") {
|
||||
t.Fatalf("session/load error = %q, want holder wording", resp.Error.Message)
|
||||
}
|
||||
if strings.Contains(resp.Error.Message, path) {
|
||||
t.Fatalf("session/load error leaks the session path: %q", resp.Error.Message)
|
||||
}
|
||||
|
||||
// The refused load must not have bound the session: a prompt is unknown.
|
||||
promptResp := client.call(t, "session/prompt", SessionPromptParams{
|
||||
SessionID: id,
|
||||
Prompt: []ContentBlock{{Type: "text", Text: "should not run"}},
|
||||
})
|
||||
if promptResp.Error == nil {
|
||||
t.Fatalf("session/prompt after refused load succeeded, want unknown session")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionCloseReleasesLease proves the lease taken by session/new is
|
||||
// released by session/close, so another runtime can bind the transcript.
|
||||
func TestSessionCloseReleasesLease(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
client, stop := startServer(t, leaseTestFactory(dir))
|
||||
defer stop()
|
||||
|
||||
sid := openSession(t, client)
|
||||
path := transcriptPath(dir, sid)
|
||||
|
||||
// Held while the session is open.
|
||||
if lease, err := agent.TryAcquireSessionLease(path); err == nil {
|
||||
lease.Release()
|
||||
t.Fatalf("transcript lease not held after session/new")
|
||||
}
|
||||
|
||||
resp := client.call(t, "session/close", SessionCloseParams{SessionID: sid})
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("session/close errored: %+v", resp.Error)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("transcript lease not released by session/close: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
@@ -25,6 +25,9 @@ func TestDeleteSessionFilesSweepsEventLogAndSidecars(t *testing.T) {
|
||||
if err := os.WriteFile(acpMetaPath(path), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write acp meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionConflictLog(path), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write conflict log: %v", err)
|
||||
}
|
||||
|
||||
if err := deleteSessionFiles(path); err != nil {
|
||||
t.Fatalf("deleteSessionFiles: %v", err)
|
||||
@@ -35,6 +38,7 @@ func TestDeleteSessionFilesSweepsEventLogAndSidecars(t *testing.T) {
|
||||
store.SessionMeta(path),
|
||||
store.SessionEventLog(path),
|
||||
store.SessionEventIndex(path),
|
||||
store.SessionConflictLog(path),
|
||||
} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("artifact survived delete: %s (err=%v)", p, err)
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// divergedACPSession writes a transcript to path whose on-disk content has
|
||||
// diverged from the returned in-memory session, so the next Snapshot on a
|
||||
// controller holding the stale session hits a conflict and retargets to a
|
||||
// recovery branch.
|
||||
func divergedACPSession(t *testing.T, path string) *agent.Session {
|
||||
t.Helper()
|
||||
disk := agent.NewSession("sys prompt")
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := disk.Save(path); err != nil {
|
||||
t.Fatalf("save disk session: %v", err)
|
||||
}
|
||||
|
||||
stale := agent.NewSession("sys prompt")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
return stale
|
||||
}
|
||||
|
||||
// primaryRecoveryFiles filters a recovery-branch glob down to primary session
|
||||
// transcripts, dropping the .events.jsonl / .guardian.jsonl sidecars that the
|
||||
// *-recovery-*.jsonl pattern also matches.
|
||||
func primaryRecoveryFiles(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches: %v", err)
|
||||
}
|
||||
primary := matches[:0]
|
||||
for _, path := range matches {
|
||||
base := filepath.Base(path)
|
||||
if strings.HasSuffix(base, ".events.jsonl") || strings.HasSuffix(base, ".guardian.jsonl") {
|
||||
continue
|
||||
}
|
||||
primary = append(primary, path)
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func assertACPSessionOnRecoveryPath(t *testing.T, sess *acpSession, originalPath, recoveryPath string) {
|
||||
t.Helper()
|
||||
if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
|
||||
t.Fatalf("session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
transcript := sess.transcript
|
||||
lease := sess.lease
|
||||
sess.mu.Unlock()
|
||||
if transcript != recoveryPath {
|
||||
t.Fatalf("session transcript = %q, want recovery path %q", transcript, recoveryPath)
|
||||
}
|
||||
if lease == nil || lease.Path() != agent.CanonicalSessionPath(recoveryPath) {
|
||||
got := ""
|
||||
if lease != nil {
|
||||
got = lease.Path()
|
||||
}
|
||||
t.Fatalf("session lease path = %q, want recovery path %q", got, recoveryPath)
|
||||
}
|
||||
// The original transcript's lease must have been released by the move so
|
||||
// another runtime can bind it.
|
||||
orig, err := agent.TryAcquireSessionLease(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("original transcript lease should be free after recovery move: %v", err)
|
||||
}
|
||||
orig.Release()
|
||||
}
|
||||
|
||||
// TestACPRebuildSessionContinuesRecoveryPathAfterSnapshotConflict is the ACP
|
||||
// twin of the desktop rebuild fix: when the pre-rebuild Snapshot hits a
|
||||
// conflict and retargets the old controller to a recovery branch, the session
|
||||
// bookkeeping must follow at commit time (sessionRecoveredHandler moves
|
||||
// sess.transcript and the lease), and AdoptHistory must bind the replacement
|
||||
// controller to that recovery path. A pre-snapshot capture bound the
|
||||
// just-recovered transcript back to the original file, so every later save
|
||||
// re-conflicted and derived yet another recovery branch.
|
||||
func TestACPRebuildSessionContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
originalPath := filepath.Join(dir, "acp-switch-conflict.jsonl")
|
||||
stale := divergedACPSession(t, originalPath)
|
||||
|
||||
sink := newUpdateSink(&fakeNotifier{}, "sess-recovery")
|
||||
sess := &acpSession{
|
||||
id: "sess-recovery",
|
||||
sink: sink,
|
||||
cwd: dir,
|
||||
model: "fast",
|
||||
transcript: originalPath,
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire original session lease: %v", err)
|
||||
}
|
||||
sess.lease = lease
|
||||
t.Cleanup(sess.releaseSessionLease)
|
||||
|
||||
svc := &service{
|
||||
factory: &configurableFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{sess.id: sess},
|
||||
}
|
||||
oldCtrl := control.New(control.Options{
|
||||
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "fast",
|
||||
OnSessionRecovered: svc.sessionRecoveredHandler(sess.id),
|
||||
})
|
||||
sess.ctrl = oldCtrl
|
||||
|
||||
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}); err != nil {
|
||||
t.Fatalf("rebuildSession: %v", err)
|
||||
}
|
||||
if sess.ctrl == oldCtrl {
|
||||
t.Fatal("session controller was not replaced")
|
||||
}
|
||||
|
||||
recoveryPath := sess.ctrl.SessionPath()
|
||||
assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
|
||||
|
||||
// The rebuilt controller adopted the recovery file's baseline, so its next
|
||||
// snapshot must not derive a second recovery branch.
|
||||
if err := sess.ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot after rebuild: %v", err)
|
||||
}
|
||||
if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", primary, recoveryPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPPersistAfterTurnMovesBookkeepingToRecoveryPath covers the autosave
|
||||
// path: a turn-end Snapshot in persistAfterTurn that recovers onto a recovery
|
||||
// branch must move sess.transcript and the session lease with the controller,
|
||||
// so session/prompt reports the live file, session/delete destroys it, and the
|
||||
// recovery transcript stays lease-guarded against other runtimes.
|
||||
func TestACPPersistAfterTurnMovesBookkeepingToRecoveryPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
originalPath := filepath.Join(dir, "acp-autosave-conflict.jsonl")
|
||||
stale := divergedACPSession(t, originalPath)
|
||||
|
||||
sink := newUpdateSink(&fakeNotifier{}, "sess-autosave")
|
||||
sess := &acpSession{
|
||||
id: "sess-autosave",
|
||||
sink: sink,
|
||||
cwd: dir,
|
||||
model: "fast",
|
||||
transcript: originalPath,
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire original session lease: %v", err)
|
||||
}
|
||||
sess.lease = lease
|
||||
t.Cleanup(sess.releaseSessionLease)
|
||||
|
||||
svc := &service{
|
||||
factory: &configurableFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{sess.id: sess},
|
||||
}
|
||||
ctrl := control.New(control.Options{
|
||||
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "fast",
|
||||
OnSessionRecovered: svc.sessionRecoveredHandler(sess.id),
|
||||
})
|
||||
sess.ctrl = ctrl
|
||||
t.Cleanup(ctrl.Close)
|
||||
|
||||
sess.persistAfterTurn("hello")
|
||||
|
||||
recoveryPath := ctrl.SessionPath()
|
||||
assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
|
||||
if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after autosave = %v, want only %q", primary, recoveryPath)
|
||||
}
|
||||
// The next turn-end autosave writes the recovery file the session now
|
||||
// owns; it must not derive a second recovery branch.
|
||||
sess.persistAfterTurn("again")
|
||||
if got := ctrl.SessionPath(); got != recoveryPath {
|
||||
t.Fatalf("controller session path after second autosave = %q, want %q", got, recoveryPath)
|
||||
}
|
||||
if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after second autosave = %v, want only %q", primary, recoveryPath)
|
||||
}
|
||||
}
|
||||
|
||||
// recoverACPSessionAndRestart drives an autosave recovery for session id in
|
||||
// dir, then simulates a process restart: the live session's lease is released,
|
||||
// its controller closed, and a fresh service (empty session registry, same
|
||||
// session dir) is returned alongside the original and recovery paths.
|
||||
func recoverACPSessionAndRestart(t *testing.T, dir, id string) (originalPath, recoveryPath string, restarted *service) {
|
||||
t.Helper()
|
||||
originalPath = transcriptPath(dir, id)
|
||||
stale := divergedACPSession(t, originalPath)
|
||||
|
||||
svc := &service{
|
||||
factory: &configurableFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{},
|
||||
}
|
||||
sess := &acpSession{
|
||||
id: id,
|
||||
sink: newUpdateSink(&fakeNotifier{}, id),
|
||||
cwd: dir,
|
||||
model: "fast",
|
||||
title: "recovered title",
|
||||
transcript: originalPath,
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire original session lease: %v", err)
|
||||
}
|
||||
sess.lease = lease
|
||||
svc.sessions[id] = sess
|
||||
ctrl := control.New(control.Options{
|
||||
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "fast",
|
||||
OnSessionRecovered: svc.sessionRecoveredHandler(id),
|
||||
})
|
||||
sess.ctrl = ctrl
|
||||
|
||||
sess.persistAfterTurn("hello")
|
||||
recoveryPath = ctrl.SessionPath()
|
||||
assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
|
||||
|
||||
sess.releaseSessionLease()
|
||||
ctrl.Close()
|
||||
restarted = &service{
|
||||
conn: NewConn(strings.NewReader(""), io.Discard),
|
||||
factory: &configurableFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{},
|
||||
}
|
||||
return originalPath, recoveryPath, restarted
|
||||
}
|
||||
|
||||
// TestACPLoadAfterRestartFollowsRecoveryTranscript covers the restart half of
|
||||
// the recovery move: session/load and session/resume resolve the session id to
|
||||
// the transcript the session actually lives in. Without the id-keyed redirect,
|
||||
// a restart reopened the pre-recovery file and the user's recovered work
|
||||
// silently vanished from ACP's view.
|
||||
func TestACPLoadAfterRestartFollowsRecoveryTranscript(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := "sess-restart"
|
||||
originalPath, recoveryPath, svc := recoverACPSessionAndRestart(t, dir, id)
|
||||
|
||||
if _, err := svc.openExistingSession(context.Background(), "session/load", id, dir, nil, false); err != nil {
|
||||
t.Fatalf("openExistingSession after restart: %v", err)
|
||||
}
|
||||
loaded := svc.session(id)
|
||||
if loaded == nil {
|
||||
t.Fatal("session not registered after load")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
loaded.releaseSessionLease()
|
||||
loaded.ctrl.Close()
|
||||
})
|
||||
assertACPSessionOnRecoveryPath(t, loaded, originalPath, recoveryPath)
|
||||
if got := loaded.ctrl.SessionPath(); got != recoveryPath {
|
||||
t.Fatalf("loaded controller session path = %q, want recovery path %q", got, recoveryPath)
|
||||
}
|
||||
// The test factory's controller has no executor, so prove the content via
|
||||
// the transcript ACP now points at: it must hold the recovered local line,
|
||||
// not the pre-recovery disk line.
|
||||
resumed, err := agent.LoadSession(loaded.transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("load resolved transcript: %v", err)
|
||||
}
|
||||
msgs := resumed.Snapshot()
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("resolved transcript is empty")
|
||||
}
|
||||
if got := msgs[len(msgs)-1].Content; got != "local second" {
|
||||
t.Fatalf("resolved transcript last message = %q, want recovered local transcript (%q)", got, "local second")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPDeleteAfterRestartRemovesRecoveryAndIDKeyedFiles: session/delete on a
|
||||
// non-live recovered session must remove both the recovery transcript (the
|
||||
// session's live file) and the id-keyed original, or the survivor resurfaces
|
||||
// in session/list as a ghost that can never be deleted by id.
|
||||
func TestACPDeleteAfterRestartRemovesRecoveryAndIDKeyedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := "sess-del"
|
||||
originalPath, recoveryPath, svc := recoverACPSessionAndRestart(t, dir, id)
|
||||
|
||||
raw, err := json.Marshal(SessionDeleteParams{SessionID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delete params: %v", err)
|
||||
}
|
||||
if _, err := svc.sessionDelete(context.Background(), raw); err != nil {
|
||||
t.Fatalf("sessionDelete after restart: %v", err)
|
||||
}
|
||||
for _, path := range []string{originalPath, recoveryPath, acpMetaPath(originalPath), acpMetaPath(recoveryPath)} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s should be removed by session/delete, stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
res, err := svc.sessionList(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("sessionList after delete: %v", err)
|
||||
}
|
||||
if sessions := res.(SessionListResult).Sessions; len(sessions) != 0 {
|
||||
t.Fatalf("session list after delete = %#v, want empty", sessions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPSessionListAfterRecoveryShowsSingleActiveEntry: after a recovery the
|
||||
// id-keyed sidecar becomes a redirect, and session/list must present exactly
|
||||
// one entry for the id, backed by the active recovery transcript's metadata
|
||||
// (the live title), never the stale pre-recovery sidecar.
|
||||
func TestACPSessionListAfterRecoveryShowsSingleActiveEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := "sess-list"
|
||||
_, _, svc := recoverACPSessionAndRestart(t, dir, id)
|
||||
|
||||
res, err := svc.sessionList(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("sessionList after recovery: %v", err)
|
||||
}
|
||||
sessions := res.(SessionListResult).Sessions
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("session list after recovery = %#v, want exactly one entry", sessions)
|
||||
}
|
||||
if sessions[0].SessionID != id {
|
||||
t.Fatalf("session list entry id = %q, want %q", sessions[0].SessionID, id)
|
||||
}
|
||||
if sessions[0].Title != "recovered title" {
|
||||
t.Fatalf("session list entry title = %q, want the active transcript's title %q", sessions[0].Title, "recovered title")
|
||||
}
|
||||
}
|
||||
+199
-48
@@ -23,6 +23,7 @@ import (
|
||||
"reasonix/internal/nilutil"
|
||||
"reasonix/internal/planmode"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/shellparse"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
@@ -273,6 +274,10 @@ type Agent struct {
|
||||
// server's readOnlyHint for plan-mode execution without changing tool schemas.
|
||||
planModeReadOnlyTrust PlanModeReadOnlyTrustGate
|
||||
|
||||
// sandboxEscapeApprover, when non-nil, can ask the user whether one shell
|
||||
// command may rerun unconfined after the OS sandbox failed to start.
|
||||
sandboxEscapeApprover sandbox.EscapeApprover
|
||||
|
||||
// hooks, when non-nil, fires PreToolUse / PostToolUse shell hooks around each
|
||||
// tool call. nil disables hook firing.
|
||||
hooks ToolHooks
|
||||
@@ -380,18 +385,41 @@ type Agent struct {
|
||||
compactStuck bool
|
||||
consecutiveCompacts int
|
||||
|
||||
// stormSig / stormCount track a run of turns that keep failing the same way so
|
||||
// the loop can break a death-spiral. The signature is each call's (tool, error)
|
||||
// in order, NOT (tool, args): a stuck model reliably reworks the arguments
|
||||
// cosmetically (a re-worded essay, a reordered object) while the call fails
|
||||
// identically every time — keying on args misses the loop entirely (observed
|
||||
// live against truncated tool-call arguments). Because errors that embed their
|
||||
// subject (e.g. "file not found: /x") differ per target, genuine varied probing
|
||||
// does not collapse to one signature. Reset whenever a turn does anything else
|
||||
// (a different failure shape, or any success). See applyStormBreaker.
|
||||
// stormSig / stormCount track a run of turns that keep failing or getting
|
||||
// blocked the same way so the loop can break a death-spiral. The signature is
|
||||
// each call's (tool, error/blocker) in order, NOT (tool, args): a stuck model
|
||||
// reliably reworks the arguments cosmetically (a re-worded essay, a reordered
|
||||
// object, a different shell command) while the host returns the same refusal or
|
||||
// failure every time — keying on args misses the loop entirely. Because errors
|
||||
// that embed their subject (e.g. "file not found: /x") differ per target,
|
||||
// genuine varied probing does not collapse to one signature. Reset whenever a
|
||||
// turn does anything else (a different failure/block shape, or any success).
|
||||
// See applyStormBreaker.
|
||||
stormSig string
|
||||
stormCount int
|
||||
|
||||
// blockedTurnStreak counts consecutive turns in which every tool call was
|
||||
// blocked by the host (permission, plan mode, hook, or loop guard).
|
||||
// stormSig catches a model fixated on one call shape; this catches a model
|
||||
// rotating between blocked shapes — alternating tools, reordering a batch,
|
||||
// or blockers whose text varies per attempt — which is zero progress all
|
||||
// the same. Reset by any turn containing a non-blocked outcome and at the
|
||||
// start of each user turn. See applyStormBreaker.
|
||||
blockedTurnStreak int
|
||||
|
||||
// loopGuardArmed / loopGuardReceiptMark let final readiness stand down
|
||||
// after a loop guard fired this user turn: once the host has told the model
|
||||
// to stop retrying and report the blocker, demanding the receipts that the
|
||||
// blocker prevents would restart the loop the guard just broke. The mark is
|
||||
// the evidence-ledger receipt count from just before the guarded batch, so
|
||||
// real progress — a successful write or command receipt landing after it —
|
||||
// revokes the pass, while the bookkeeping the guard itself recommends
|
||||
// (ask, todo_write, complete_step) keeps it. Host state, not message text:
|
||||
// tool output that merely quotes "[loop guard]" must not unlock readiness.
|
||||
// Reset at the start of each user turn. See loopGuardAllowsFinal.
|
||||
loopGuardArmed bool
|
||||
loopGuardReceiptMark int
|
||||
|
||||
// repeatSuccessCounts tracks write-like tool calls that have already
|
||||
// succeeded in this user turn. This catches the complementary loop shape to
|
||||
// stormSig: a model keeps doing the same successful write, so there is no
|
||||
@@ -587,6 +615,15 @@ func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) {
|
||||
a.planModeReadOnlyTrust = g
|
||||
}
|
||||
|
||||
// SetSandboxEscapeApprover installs the optional one-shot approval path used by
|
||||
// the bash tool when an enforced OS sandbox fails to start.
|
||||
func (a *Agent) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
|
||||
if nilutil.IsNil(g) {
|
||||
g = nil
|
||||
}
|
||||
a.sandboxEscapeApprover = g
|
||||
}
|
||||
|
||||
func (a *Agent) withTurnPreferences(input string) string {
|
||||
if a == nil {
|
||||
return input
|
||||
@@ -760,6 +797,10 @@ type Options struct {
|
||||
// plan mode would otherwise block them. nil keeps fail-closed behavior.
|
||||
PlanModeReadOnlyTrustGate PlanModeReadOnlyTrustGate
|
||||
|
||||
// SandboxEscapeApprover confirms a one-shot unconfined shell rerun after an
|
||||
// enforced OS sandbox fails. nil keeps fail-closed behavior.
|
||||
SandboxEscapeApprover sandbox.EscapeApprover
|
||||
|
||||
// Context management. ContextWindow <= 0 disables compaction. Ratios and
|
||||
// RecentKeep fall back to defaults when unset.
|
||||
ContextWindow int
|
||||
@@ -848,6 +889,10 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
if nilutil.IsNil(planModeReadOnlyTrust) {
|
||||
planModeReadOnlyTrust = nil
|
||||
}
|
||||
sandboxEscapeApprover := opts.SandboxEscapeApprover
|
||||
if nilutil.IsNil(sandboxEscapeApprover) {
|
||||
sandboxEscapeApprover = nil
|
||||
}
|
||||
hooks := opts.Hooks
|
||||
if nilutil.IsNil(hooks) {
|
||||
hooks = nil
|
||||
@@ -878,6 +923,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
sink: sink,
|
||||
gate: gate,
|
||||
planModeReadOnlyTrust: planModeReadOnlyTrust,
|
||||
sandboxEscapeApprover: sandboxEscapeApprover,
|
||||
hooks: hooks,
|
||||
jobs: opts.Jobs,
|
||||
evidence: evidence.NewLedger(),
|
||||
@@ -934,6 +980,9 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
a.evidence.Reset()
|
||||
}
|
||||
a.repeatSuccessCounts = nil
|
||||
a.blockedTurnStreak = 0
|
||||
a.loopGuardArmed = false
|
||||
a.loopGuardReceiptMark = 0
|
||||
a.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
rawInput := input
|
||||
memoryCompilerInput := rawInput
|
||||
@@ -1211,6 +1260,9 @@ func (a *Agent) finalReadinessCheck() finalReadinessCheck {
|
||||
writer, hasWriter := a.evidence.LatestSuccessfulWriterIndex()
|
||||
if !hasWriter {
|
||||
if len(missing) > 0 {
|
||||
if a.loopGuardAllowsFinal() {
|
||||
return out
|
||||
}
|
||||
out.reason = strings.Join(missing, "; ")
|
||||
}
|
||||
return out
|
||||
@@ -1235,10 +1287,41 @@ func (a *Agent) finalReadinessCheck() finalReadinessCheck {
|
||||
if len(missing) == 0 {
|
||||
return out
|
||||
}
|
||||
if a.loopGuardAllowsFinal() {
|
||||
return out
|
||||
}
|
||||
out.reason = strings.Join(missing, "; ")
|
||||
return out
|
||||
}
|
||||
|
||||
// armLoopGuardPass records that a loop guard fired this user turn.
|
||||
// receiptMark is the evidence-ledger receipt count from just before the
|
||||
// guarded batch ran, so a successful write or command receipt recorded after
|
||||
// it counts as real progress and revokes the pass (see loopGuardAllowsFinal).
|
||||
func (a *Agent) armLoopGuardPass(receiptMark int) {
|
||||
a.loopGuardArmed = true
|
||||
a.loopGuardReceiptMark = receiptMark
|
||||
}
|
||||
|
||||
// loopGuardAllowsFinal reports whether final readiness should stand down: a
|
||||
// loop guard fired this user turn and no host-observable progress — a
|
||||
// successful write or command receipt — has landed since. In that state the
|
||||
// missing receipts are exactly what the blocker prevents, so demanding them
|
||||
// would restart the retry loop the guard just broke; the model must be free to
|
||||
// report the blocker instead. The bookkeeping the guard recommends (ask,
|
||||
// todo_write, complete_step) produces neither write nor command receipts, so
|
||||
// it keeps the pass; real progress revokes it because receipts are obtainable
|
||||
// again and readiness should resume enforcing them.
|
||||
func (a *Agent) loopGuardAllowsFinal() bool {
|
||||
if a == nil || !a.loopGuardArmed {
|
||||
return false
|
||||
}
|
||||
if a.evidence == nil {
|
||||
return true
|
||||
}
|
||||
return !a.evidence.HasWriteOrCommandSince(a.loopGuardReceiptMark)
|
||||
}
|
||||
|
||||
func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
@@ -1446,7 +1529,7 @@ func finalReadinessCheckSource(check instruction.VerifyCheck) string {
|
||||
}
|
||||
|
||||
func finalReadinessRetryMessage(reason string) string {
|
||||
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run the required tool calls, then answer when readiness is satisfied. If the blocked item needs user input, a user-owned choice, or manual review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so."
|
||||
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run only the required tool calls, then answer when readiness is satisfied. Prefer signing off completed work with complete_step and updating todo_write from existing receipts; do not run exploratory bash commands just to satisfy readiness. If a permission, plan-mode, hook, or loop-guard block prevents the required receipt, do not keep retrying the blocked command with different wording. If the blocked item needs user input, a user-owned choice, or manual review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so."
|
||||
}
|
||||
|
||||
func shouldNudgeExecutorHandoff(input, answer string) bool {
|
||||
@@ -1782,6 +1865,13 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []s
|
||||
results := make([]string, len(calls))
|
||||
outcomes := make([]toolOutcome, len(calls))
|
||||
durations := make([]int64, len(calls))
|
||||
// Snapshot the receipt count before the batch runs: if a loop guard fires
|
||||
// for this batch, successes recorded during it (a mixed batch where only one
|
||||
// call was guard-blocked) must already count as progress against the pass.
|
||||
receiptMark := 0
|
||||
if a.evidence != nil {
|
||||
receiptMark = a.evidence.Len()
|
||||
}
|
||||
run := func(i int) {
|
||||
start := time.Now()
|
||||
outcomes[i] = a.executeOne(ctx, calls[i])
|
||||
@@ -1877,7 +1967,7 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []s
|
||||
a.compilerTurn.RecordToolResults(records)
|
||||
}
|
||||
if !cancelled {
|
||||
a.applyStormBreaker(calls, outcomes, results)
|
||||
a.applyStormBreaker(calls, outcomes, results, receiptMark)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -1989,58 +2079,116 @@ const stormBreakThreshold = 3
|
||||
// no-op/write loop and should be redirected to a different tool or final answer.
|
||||
const repeatSuccessBreakThreshold = 2
|
||||
|
||||
// applyStormBreaker detects a run of identically-failing turns and, past the
|
||||
// loopGuardBlockErrMsg is the errMsg carried by a repeat-success loop-guard
|
||||
// block. applyStormBreaker matches it to arm the final-readiness loop-guard
|
||||
// pass, since that guard also invites the model to report the blocker.
|
||||
const loopGuardBlockErrMsg = "blocked by loop guard"
|
||||
|
||||
// applyStormBreaker detects a run of zero-progress turns and, past the
|
||||
// threshold, rewrites the model-facing result (results[0]) into a directive to
|
||||
// change approach. It keys on each call's (tool, error) — not its args — because a
|
||||
// stuck model reworks the arguments cosmetically while failing identically (see
|
||||
// the stormSig field doc). A turn is a fixation candidate only when every one of
|
||||
// its calls errored and none was merely blocked by plan mode / permissions (those
|
||||
// carry a clear, distinct message the model can already act on). Any success, any
|
||||
// block, or a different batch shape is varied work, so it resets the counter. This
|
||||
// covers both the single-call spiral and a repeated multi-call batch. The hard
|
||||
// maxSteps guard remains the ultimate backstop; this just keeps the loop from
|
||||
// burning that whole budget bouncing off the same failure.
|
||||
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string) {
|
||||
// change approach. Two detectors, because a stuck model varies its retries two
|
||||
// ways. The signature detector keys on each call's (tool, error/blocker) — not
|
||||
// its args — since a stuck model reworks the arguments cosmetically while
|
||||
// hitting the same host refusal or failure (see the stormSig field doc). The
|
||||
// streak detector counts consecutive turns in which every call was blocked,
|
||||
// regardless of shape: rotating tools, reordering a batch, or a blocker whose
|
||||
// text varies per attempt escapes the signature but is still zero progress —
|
||||
// only a host refusal (not a plain error) proves that, so the streak requires
|
||||
// blocked outcomes. Any success resets both. When a guard fires — or when a
|
||||
// call in the batch was already blocked by the per-call repeat-success guard —
|
||||
// the final-readiness loop-guard pass is armed so the model may report the
|
||||
// blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
|
||||
// ultimate backstop; this just keeps the loop from burning that whole budget
|
||||
// bouncing off the same host refusals.
|
||||
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
|
||||
allBlocked := len(outcomes) > 0
|
||||
for _, outcome := range outcomes {
|
||||
if !outcome.blocked {
|
||||
allBlocked = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allBlocked {
|
||||
a.blockedTurnStreak++
|
||||
} else {
|
||||
a.blockedTurnStreak = 0
|
||||
}
|
||||
for _, outcome := range outcomes {
|
||||
if outcome.blocked && outcome.errMsg == loopGuardBlockErrMsg {
|
||||
a.armLoopGuardPass(receiptMark)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sig, ok := batchStormSignature(calls, outcomes)
|
||||
if !ok {
|
||||
switch {
|
||||
case !ok:
|
||||
a.stormSig, a.stormCount = "", 0
|
||||
return
|
||||
}
|
||||
if sig != a.stormSig {
|
||||
case sig != a.stormSig:
|
||||
a.stormSig, a.stormCount = sig, 1
|
||||
default:
|
||||
a.stormCount++
|
||||
}
|
||||
stormHit := ok && a.stormCount >= stormBreakThreshold
|
||||
streakHit := allBlocked && a.blockedTurnStreak >= stormBreakThreshold
|
||||
if !stormHit && !streakHit {
|
||||
return
|
||||
}
|
||||
a.stormCount++
|
||||
if a.stormCount < stormBreakThreshold {
|
||||
return
|
||||
|
||||
const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
|
||||
var guard, notice string
|
||||
if stormHit {
|
||||
subject := fmt.Sprintf("%q", calls[0].Name)
|
||||
short := calls[0].Name
|
||||
if len(calls) > 1 {
|
||||
subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
|
||||
short = fmt.Sprintf("a batch of %d calls", len(calls))
|
||||
}
|
||||
anyBlocked := false
|
||||
for _, outcome := range outcomes {
|
||||
if outcome.blocked {
|
||||
anyBlocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
action := "failed"
|
||||
advice := "Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer."
|
||||
if anyBlocked {
|
||||
action = "been blocked or failed"
|
||||
advice = blockedAdvice
|
||||
}
|
||||
guard = fmt.Sprintf(
|
||||
"[loop guard] %s has now %s %d times in a row with the same host response. Re-sending it — even with the wording changed — will not help: the calls keep hitting the same outcome. %s",
|
||||
subject, action, a.stormCount, advice)
|
||||
notice = fmt.Sprintf(
|
||||
"loop guard: %s hit the same host response %d× — nudging the model to change approach",
|
||||
short, a.stormCount)
|
||||
} else {
|
||||
guard = fmt.Sprintf(
|
||||
"[loop guard] every tool call in the last %d turns has been blocked by the host (permission, plan mode, hook, or loop guard). Switching tools, reordering calls, or rewording arguments will not help while the blockers stand. %s",
|
||||
a.blockedTurnStreak, blockedAdvice)
|
||||
notice = fmt.Sprintf(
|
||||
"loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
|
||||
a.blockedTurnStreak)
|
||||
}
|
||||
subject := fmt.Sprintf("%q", calls[0].Name)
|
||||
short := calls[0].Name
|
||||
if len(calls) > 1 {
|
||||
subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
|
||||
short = fmt.Sprintf("a batch of %d calls", len(calls))
|
||||
}
|
||||
results[0] = outcomes[0].output + fmt.Sprintf(
|
||||
"\n\n[loop guard] %s has now failed %d times in a row with the same error. Re-sending it — even with the wording changed — will not help: the calls keep failing the same way. Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer.",
|
||||
subject, a.stormCount)
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(
|
||||
"loop guard: %s failed %d× the same way — nudging the model to change approach",
|
||||
short, a.stormCount)})
|
||||
results[0] = outcomes[0].output + "\n\n" + guard
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: notice})
|
||||
a.armLoopGuardPass(receiptMark)
|
||||
}
|
||||
|
||||
// batchStormSignature returns a per-turn fixation signature — each call's
|
||||
// (name, error) in order — and ok=true only when every call errored and none was
|
||||
// merely blocked. ok=false (any success or block) means the turn made varied
|
||||
// progress, so the caller resets the counter. Keying on the error rather than the
|
||||
// args is deliberate: a stuck model reworks the arguments while failing the same
|
||||
// way, so identical-args matching would miss the loop.
|
||||
// (name, error/blocker) in order — and ok=true only when every call errored or
|
||||
// was blocked. ok=false (any success) means the turn made progress, so the
|
||||
// caller resets the counter. Keying on the host response rather than the args is
|
||||
// deliberate: a stuck model reworks the arguments while hitting the same
|
||||
// response, so identical-args matching would miss the loop.
|
||||
func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (string, bool) {
|
||||
if len(calls) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i := range calls {
|
||||
if outcomes[i].errMsg == "" || outcomes[i].blocked {
|
||||
if outcomes[i].errMsg == "" {
|
||||
return "", false
|
||||
}
|
||||
sb.WriteString(calls[i].Name)
|
||||
@@ -2080,7 +2228,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
return toolOutcome{
|
||||
output: out,
|
||||
blocked: true,
|
||||
errMsg: "blocked by loop guard",
|
||||
errMsg: loopGuardBlockErrMsg,
|
||||
}
|
||||
}
|
||||
if out, blocked := a.staleAnchorEditBlock(call); blocked {
|
||||
@@ -2190,6 +2338,9 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
if a.jobs != nil {
|
||||
cctx = jobs.WithManager(cctx, a.jobs)
|
||||
}
|
||||
if a.sandboxEscapeApprover != nil {
|
||||
cctx = sandbox.WithEscapeApprover(cctx, a.sandboxEscapeApprover)
|
||||
}
|
||||
if v := a.responseLanguage.Load(); v != nil {
|
||||
if lang, ok := v.(string); ok {
|
||||
cctx = WithResponseLanguagePreference(cctx, lang)
|
||||
|
||||
@@ -37,9 +37,15 @@ type BranchMeta struct {
|
||||
Recovered bool `json:"recovered,omitempty"`
|
||||
RecoveryReason string `json:"recovery_reason,omitempty"`
|
||||
RecoveryDigest string `json:"recovery_digest,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
ContentDigest string `json:"content_digest,omitempty"`
|
||||
WriterID string `json:"writer_id,omitempty"`
|
||||
// RecoveryDepth counts how many recovery forks separate this branch from a
|
||||
// normal session (1 = forked from a normal session). SaveRecoveryBranch
|
||||
// refuses to fork past SessionRecoveryMaxDepth so a conflict loop cannot
|
||||
// spawn unbounded nested recovery chains (#5993 reached 8 levels). Legacy
|
||||
// recovery metas without the field are treated as depth 1.
|
||||
RecoveryDepth int `json:"recovery_depth,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
ContentDigest string `json:"content_digest,omitempty"`
|
||||
WriterID string `json:"writer_id,omitempty"`
|
||||
// SchemaVersion records the BranchMeta version that last wrote the listing
|
||||
// fields (Turns/Preview) FROM the session's content. It is stamped only by the
|
||||
// writers that actually derive those counts — Controller.snapshot's
|
||||
@@ -131,6 +137,32 @@ func LoadBranchMeta(sessionPath string) (BranchMeta, bool, error) {
|
||||
return m, true, nil
|
||||
}
|
||||
|
||||
// branchMetaReadBackoffs paces the re-reads of a branch-meta sidecar that
|
||||
// failed to load. On Windows fileutil.ReplaceFile can fall back to a
|
||||
// non-atomic in-place copy, so a concurrent reader may catch the sidecar
|
||||
// half-written (an open/read error or truncated JSON). Those tears heal in
|
||||
// milliseconds; a few short retries separate them from real corruption.
|
||||
var branchMetaReadBackoffs = []time.Duration{20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond}
|
||||
|
||||
// loadBranchMetaRetry reads the branch-meta sidecar like LoadBranchMeta but
|
||||
// retries transient failures (I/O errors and undecodable JSON) before giving
|
||||
// up. A missing sidecar is a legitimate state — a session that has never
|
||||
// recorded meta — and returns ok=false immediately without retrying.
|
||||
func loadBranchMetaRetry(sessionPath string) (BranchMeta, bool, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; ; attempt++ {
|
||||
meta, ok, err := LoadBranchMeta(sessionPath)
|
||||
if err == nil {
|
||||
return meta, ok, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt >= len(branchMetaReadBackoffs) {
|
||||
return BranchMeta{}, false, lastErr
|
||||
}
|
||||
time.Sleep(branchMetaReadBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func SaveBranchMeta(sessionPath string, m BranchMeta) error {
|
||||
return saveBranchMeta(sessionPath, m, true)
|
||||
}
|
||||
@@ -335,6 +367,11 @@ func RenameSession(sessionPath string, title string) error {
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
}
|
||||
// Read-modify-write on the sidecar: hold the per-path meta lock so a
|
||||
// concurrent save (recordSessionContentRevision) can't have its Revision
|
||||
// bump clobbered by a stale read-back here.
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
defer unlock()
|
||||
m, err := EnsureBranchMeta(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalSessionPathMatchesLeaseRegistryKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "Mixed-Case", "20260705-Test.jsonl")
|
||||
if got, want := CanonicalSessionPath(path), canonicalSessionSavePath(path); got != want {
|
||||
t.Fatalf("CanonicalSessionPath(%q) = %q, want lease key %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalSessionPathIdempotentAndEmptySafe(t *testing.T) {
|
||||
if got := CanonicalSessionPath(""); got != "" {
|
||||
t.Fatalf("empty path resolved to %q; must stay empty", got)
|
||||
}
|
||||
if got := CanonicalSessionPath(" "); got != "" {
|
||||
t.Fatalf("blank path resolved to %q; must stay empty", got)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "A", "b.jsonl")
|
||||
key := CanonicalSessionPath(path)
|
||||
if again := CanonicalSessionPath(key); again != key {
|
||||
t.Fatalf("not idempotent: %q -> %q", key, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalSessionPathFoldsCaseOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case folding is Windows-only")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "Sessions", "20260705-Test.jsonl")
|
||||
if CanonicalSessionPath(path) != CanonicalSessionPath(strings.ToUpper(path)) {
|
||||
t.Fatal("case variants of one file produced distinct keys")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/nilutil"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -183,6 +184,20 @@ func (c *Coordinator) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSandboxEscapeApprover propagates one-shot shell sandbox escape approvals to
|
||||
// both tool-using agents in two-model mode.
|
||||
func (c *Coordinator) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.plannerAgent != nil {
|
||||
c.plannerAgent.SetSandboxEscapeApprover(g)
|
||||
}
|
||||
if c.executor != nil {
|
||||
c.executor.SetSandboxEscapeApprover(g)
|
||||
}
|
||||
}
|
||||
|
||||
// Run plans with the planner model, then hands the plan to the executor.
|
||||
func (c *Coordinator) Run(ctx context.Context, input string) error {
|
||||
c.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
|
||||
@@ -412,6 +412,112 @@ func TestFinalReadinessStopsAfterRepeatedBlocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
|
||||
todoWrite, ok := tool.LookupBuiltin("todo_write")
|
||||
if !ok {
|
||||
t.Fatal("todo_write builtin not registered")
|
||||
}
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "write_file", readOnly: false})
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
reg.Add(todoWrite)
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
|
||||
{
|
||||
toolCallChunk("w1", "write_file", `{"path":"changed.go","content":"package main"}`),
|
||||
toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{{Type: provider.ChunkText, Text: "premature final"}, {Type: provider.ChunkDone}},
|
||||
{toolCallChunk("b1", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}},
|
||||
{toolCallChunk("b2", "bash", `{"command":"git status --short"}`), {Type: provider.ChunkDone}},
|
||||
{toolCallChunk("b3", "bash", `{"command":"ls -la"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
sink, notices := warnNoticeRecorder()
|
||||
a := New(prov, reg, NewSession(""), Options{
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true}},
|
||||
}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "edit with todo, then hit bash permission blocks"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 6 {
|
||||
t.Fatalf("provider calls = %d, want readiness retry, three blocked bash calls, then final", prov.call)
|
||||
}
|
||||
if !sessionHasUserMessageContaining(a.session, "final-answer readiness") {
|
||||
t.Fatal("missing synthetic readiness retry message")
|
||||
}
|
||||
if got := lastToolResult(a.session, "bash"); !strings.Contains(got, "[loop guard]") {
|
||||
t.Fatalf("last bash result = %q, want permission loop guard", got)
|
||||
}
|
||||
if got := toolResults(a.session, "bash"); len(got) != stormBreakThreshold {
|
||||
t.Fatalf("bash results = %d, want exactly %d blocked attempts", len(got), stormBreakThreshold)
|
||||
}
|
||||
if len(*notices) == 0 {
|
||||
t.Fatal("loop guard should emit a user-facing warning notice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch pins the
|
||||
// multi-call variant: the guard text lands on the batch's FIRST result, so any
|
||||
// detection keyed to the latest tool message misses it. The loop-guard pass is
|
||||
// host state and must let the model report the blocker regardless of where in
|
||||
// the batch the guard text sits.
|
||||
func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch(t *testing.T) {
|
||||
todoWrite, ok := tool.LookupBuiltin("todo_write")
|
||||
if !ok {
|
||||
t.Fatal("todo_write builtin not registered")
|
||||
}
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "write_file", readOnly: false})
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
reg.Add(todoWrite)
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
|
||||
{
|
||||
toolCallChunk("w1", "write_file", `{"path":"changed.go","content":"package main"}`),
|
||||
toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{{Type: provider.ChunkText, Text: "premature final"}, {Type: provider.ChunkDone}},
|
||||
{
|
||||
toolCallChunk("b1a", "bash", `{"command":"go test ./..."}`),
|
||||
toolCallChunk("b1b", "bash", `{"command":"go vet ./..."}`),
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
toolCallChunk("b2a", "bash", `{"command":"git status --short"}`),
|
||||
toolCallChunk("b2b", "bash", `{"command":"git diff --stat"}`),
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
toolCallChunk("b3a", "bash", `{"command":"ls -la"}`),
|
||||
toolCallChunk("b3b", "bash", `{"command":"pwd"}`),
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true}},
|
||||
}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "edit with todo, then hit batched bash permission blocks"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 6 {
|
||||
t.Fatalf("provider calls = %d, want readiness retry, three blocked batches, then final", prov.call)
|
||||
}
|
||||
results := toolResults(a.session, "bash")
|
||||
if len(results) != 2*stormBreakThreshold {
|
||||
t.Fatalf("bash results = %d, want %d blocked attempts across three batches", len(results), 2*stormBreakThreshold)
|
||||
}
|
||||
if !strings.Contains(results[len(results)-2], "[loop guard]") {
|
||||
t.Fatalf("first result of the guarded batch should carry the loop guard, got: %q", results[len(results)-2])
|
||||
}
|
||||
if strings.Contains(results[len(results)-1], "[loop guard]") {
|
||||
t.Fatalf("last result of the guarded batch should stay untouched (the pass must not depend on it), got: %q", results[len(results)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoWriteOnlyTurnMayEndWithIncompleteTodos(t *testing.T) {
|
||||
todoWrite, ok := tool.LookupBuiltin("todo_write")
|
||||
if !ok {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/instruction"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func readinessLedger(receipts ...evidence.Receipt) *evidence.Ledger {
|
||||
@@ -98,6 +99,75 @@ func TestFinalReadinessCheckAuditsIncompleteTodos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalReadinessAllowsFinalAfterLoopGuardedToolBlocker(t *testing.T) {
|
||||
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
|
||||
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
|
||||
ledger := readinessLedger(writer, todo)
|
||||
a := &Agent{evidence: ledger}
|
||||
a.armLoopGuardPass(ledger.Len())
|
||||
|
||||
got := a.finalReadinessCheck()
|
||||
if !got.applies {
|
||||
t.Fatalf("finalReadinessCheck() applies = false, want true audit after loop guard")
|
||||
}
|
||||
if got.reason != "" {
|
||||
t.Fatalf("finalReadinessCheck() reason = %q, want loop guard to allow final blocker report", got.reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinalReadinessLoopGuardPassSurvivesBookkeeping proves the exact actions
|
||||
// the loop guard recommends — ask, todo_write, complete_step — do not revoke
|
||||
// the pass: the model must be able to record the blocker and then report it.
|
||||
func TestFinalReadinessLoopGuardPassSurvivesBookkeeping(t *testing.T) {
|
||||
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
|
||||
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
|
||||
ledger := readinessLedger(writer, todo)
|
||||
a := &Agent{evidence: ledger}
|
||||
a.armLoopGuardPass(ledger.Len())
|
||||
|
||||
ledger.Record(evidence.Receipt{ToolName: "ask", Success: true})
|
||||
ledger.Record(evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}})
|
||||
ledger.Record(evidence.Receipt{ToolName: "complete_step", Success: true, Step: "edit"})
|
||||
|
||||
if got := a.finalReadinessCheck(); got.reason != "" {
|
||||
t.Fatalf("finalReadinessCheck() reason = %q, want bookkeeping after the guard to keep the pass", got.reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinalReadinessLoopGuardPassRevokedByRealProgress proves a successful
|
||||
// write or command receipt after the guard revokes the pass: receipts are
|
||||
// obtainable again, so readiness resumes enforcing them.
|
||||
func TestFinalReadinessLoopGuardPassRevokedByRealProgress(t *testing.T) {
|
||||
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
|
||||
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
|
||||
ledger := readinessLedger(writer, todo)
|
||||
a := &Agent{evidence: ledger}
|
||||
a.armLoopGuardPass(ledger.Len())
|
||||
|
||||
ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
|
||||
|
||||
if got := a.finalReadinessCheck(); got.reason == "" {
|
||||
t.Fatal("finalReadinessCheck() reason empty, want real progress after the guard to revoke the pass")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinalReadinessIgnoresLoopGuardQuotedInToolOutput proves the pass is host
|
||||
// state, not message text: a tool result that merely quotes "[loop guard]"
|
||||
// (a grep over this repo, a pasted transcript) must not unlock readiness.
|
||||
func TestFinalReadinessIgnoresLoopGuardQuotedInToolOutput(t *testing.T) {
|
||||
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
|
||||
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
|
||||
sess := NewSession("")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "edit"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "b1", Name: "bash"}}})
|
||||
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "b1", Name: "bash", Content: "agent.go:2082: \"[loop guard] %s has now %s %d times\""})
|
||||
a := &Agent{evidence: readinessLedger(writer, todo), session: sess}
|
||||
|
||||
if got := a.finalReadinessCheck(); got.reason == "" {
|
||||
t.Fatal("finalReadinessCheck() reason empty, want quoted loop-guard text to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalReadinessRetryMessageKeepsUserChoicesInteractive(t *testing.T) {
|
||||
msg := finalReadinessRetryMessage("latest successful todo_write still has incomplete items: Ask user to review the doc: in_progress")
|
||||
lower := strings.ToLower(msg)
|
||||
@@ -106,6 +176,8 @@ func TestFinalReadinessRetryMessageKeepsUserChoicesInteractive(t *testing.T) {
|
||||
"wait for its tool result",
|
||||
"do not ask in prose",
|
||||
"do not claim the user answered",
|
||||
"do not run exploratory bash commands",
|
||||
"do not keep retrying the blocked command",
|
||||
} {
|
||||
if !strings.Contains(lower, want) {
|
||||
t.Fatalf("finalReadinessRetryMessage() missing %q:\n%s", want, msg)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recovery-branch garbage collection. Conflict recovery forks a copy of the
|
||||
// in-memory transcript whenever a save conflicts (#5993); the triggers are
|
||||
// fixed, but every fork that ever happened still sits in the session list
|
||||
// until the user trashes it by hand. Most of them preserve nothing: the
|
||||
// original session went on to contain everything the fork saved. Those — and
|
||||
// only those — are safe to reclaim automatically.
|
||||
|
||||
// RecoveryGCGracePeriod is how long a reclaimable recovery branch must sit
|
||||
// idle before GC may collect it. A fresh fork is part of an active conflict
|
||||
// flow — the user may be comparing it against the original right now.
|
||||
const RecoveryGCGracePeriod = 24 * time.Hour
|
||||
|
||||
// SessionLeaseHeld reports whether ANY live runtime — this process included —
|
||||
// holds the session's write lease. SessionLeaseHeldByOtherRuntime deliberately
|
||||
// answers false for the current process; GC needs the stricter question, since
|
||||
// a branch open in one of our own tabs is just as much in use.
|
||||
func SessionLeaseHeld(path string) bool {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := sessionLeaseOwners.Load(canonicalSessionSavePath(path)); ok {
|
||||
return true
|
||||
}
|
||||
return SessionLeaseHeldByOtherRuntime(path)
|
||||
}
|
||||
|
||||
// ReclaimableRecoveryBranches scans dir for conflict-recovery branches that
|
||||
// are safe to dispose of. Every condition must hold — when in doubt the branch
|
||||
// stays, because a recovery branch exists precisely to prevent data loss:
|
||||
//
|
||||
// 1. The branch meta says Recovered and records the fork digest.
|
||||
// 2. The transcript still matches that fork digest: the branch was never
|
||||
// continued on. A single follow-up turn disqualifies it permanently.
|
||||
// 3. The parent transcript (meta.ParentID, same directory) exists and covers
|
||||
// the branch content — equal digest, or the branch is a strict prefix
|
||||
// (allowing a compatible leading-system swap). These are the same checks
|
||||
// SaveRecoveryBranch uses to declare a recovery not needed in the first
|
||||
// place, so "covered" here means the fork preserves nothing unique.
|
||||
// 4. No live runtime holds the branch's session lease.
|
||||
// 5. The branch has been idle for at least grace.
|
||||
//
|
||||
// It returns candidate paths only; disposal (trash, delete) is caller policy.
|
||||
func ReclaimableRecoveryBranches(dir string, now time.Time, grace time.Duration) ([]string, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") || strings.HasSuffix(e.Name(), ".events.jsonl") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
if !IsVisibleSession(path) {
|
||||
continue
|
||||
}
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(meta.ParentID) == "" {
|
||||
continue
|
||||
}
|
||||
idleSince := meta.UpdatedAt
|
||||
if idleSince.IsZero() {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
idleSince = info.ModTime()
|
||||
}
|
||||
if now.Sub(idleSince) < grace {
|
||||
continue
|
||||
}
|
||||
if SessionLeaseHeld(path) {
|
||||
continue
|
||||
}
|
||||
branch, err := LoadSession(path)
|
||||
if err != nil || branch == nil {
|
||||
continue
|
||||
}
|
||||
branchMsgs := branch.Snapshot()
|
||||
branchDigest, err := digestSessionMessages(branchMsgs)
|
||||
if err != nil || digestString(branchDigest) != meta.RecoveryDigest {
|
||||
// Continued on (or undigestable): this is someone's conversation now.
|
||||
continue
|
||||
}
|
||||
parentPath := filepath.Join(dir, meta.ParentID+".jsonl")
|
||||
if parentPath == path || !IsVisibleSession(parentPath) {
|
||||
continue
|
||||
}
|
||||
parent, err := LoadSession(parentPath)
|
||||
if err != nil || parent == nil {
|
||||
continue
|
||||
}
|
||||
parentMsgs := parent.Snapshot()
|
||||
parentDigest, err := digestSessionMessages(parentMsgs)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
covered := bytes.Equal(parentDigest[:], branchDigest[:]) ||
|
||||
messagesHavePrefix(parentMsgs, branchMsgs) ||
|
||||
messagesHavePrefixWithCompatibleSystem(parentMsgs, branchMsgs)
|
||||
if !covered {
|
||||
continue
|
||||
}
|
||||
out = append(out, path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// forkRecoveryBranch builds a real conflict-recovery branch: a diverged disk
|
||||
// parent plus a stale in-memory session, forked through SaveRecoveryBranch —
|
||||
// the exact artifacts GC will meet in the field.
|
||||
func forkRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string, branchMsgs []provider.Message) {
|
||||
t.Helper()
|
||||
parentPath = filepath.Join(dir, name+".jsonl")
|
||||
disk := NewSession("sys")
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
|
||||
if err := disk.Save(parentPath); err != nil {
|
||||
t.Fatalf("Save parent: %v", err)
|
||||
}
|
||||
stale := NewSession("sys")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
|
||||
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: parentPath})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
return parentPath, info.Path, stale.Snapshot()
|
||||
}
|
||||
|
||||
// coverBranchInParent rewrites the parent so it contains the branch's content
|
||||
// plus later turns — the "original session went on and kept everything the
|
||||
// fork preserved" shape that makes the fork redundant.
|
||||
func coverBranchInParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
|
||||
t.Helper()
|
||||
merged := NewSession("")
|
||||
merged.Messages = append([]provider.Message(nil), branchMsgs...)
|
||||
merged.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
|
||||
if err := merged.Save(parentPath); err != nil {
|
||||
t.Fatalf("Save covering parent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimableRecoveryBranchesCollectsOnlyCoveredIdleForks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
later := time.Now().Add(48 * time.Hour)
|
||||
|
||||
// Covered + idle + unleased: reclaimable.
|
||||
_, covered, coveredMsgs := forkRecoveryBranch(t, dir, "covered")
|
||||
coverBranchInParent(t, filepath.Join(dir, "covered.jsonl"), coveredMsgs)
|
||||
|
||||
// Diverged parent: the fork holds turns that exist nowhere else — kept.
|
||||
forkRecoveryBranch(t, dir, "diverged")
|
||||
|
||||
// Continued on: one follow-up turn on the branch disqualifies it forever.
|
||||
continuedParent, continuedBranch, continuedMsgs := forkRecoveryBranch(t, dir, "continued")
|
||||
coverBranchInParent(t, continuedParent, continuedMsgs)
|
||||
cont, err := LoadSession(continuedBranch)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession continued branch: %v", err)
|
||||
}
|
||||
cont.Add(provider.Message{Role: provider.RoleAssistant, Content: "user kept chatting here"})
|
||||
if err := cont.Save(continuedBranch); err != nil {
|
||||
t.Fatalf("Save continued branch: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("ReclaimableRecoveryBranches: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != covered {
|
||||
t.Fatalf("reclaimable = %v, want only %q", got, covered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimableRecoveryBranchesRespectsGraceLeaseAndMissingParent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
later := time.Now().Add(48 * time.Hour)
|
||||
|
||||
parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "guarded")
|
||||
coverBranchInParent(t, parentPath, branchMsgs)
|
||||
|
||||
// Fresh fork inside the grace window: kept.
|
||||
if got, err := ReclaimableRecoveryBranches(dir, time.Now(), RecoveryGCGracePeriod); err != nil || len(got) != 0 {
|
||||
t.Fatalf("within grace = %v err=%v, want none", got, err)
|
||||
}
|
||||
|
||||
// Lease held (by this very process): kept.
|
||||
lease, err := TryAcquireSessionLease(branchPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
if !SessionLeaseHeld(branchPath) {
|
||||
t.Fatal("SessionLeaseHeld = false while this process holds the lease")
|
||||
}
|
||||
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
|
||||
t.Fatalf("with lease held = %v err=%v, want none", got, err)
|
||||
}
|
||||
lease.Release()
|
||||
if SessionLeaseHeld(branchPath) {
|
||||
t.Fatal("SessionLeaseHeld = true after release")
|
||||
}
|
||||
|
||||
// Released + idle: reclaimable now.
|
||||
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 1 || got[0] != branchPath {
|
||||
t.Fatalf("after release = %v err=%v, want %q", got, err, branchPath)
|
||||
}
|
||||
|
||||
// Parent gone: content is no longer covered anywhere — kept.
|
||||
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
|
||||
if err := os.Remove(parentPath + suffix); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove parent artifact %s: %v", suffix, err)
|
||||
}
|
||||
}
|
||||
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
|
||||
t.Fatalf("parent missing = %v err=%v, want none", got, err)
|
||||
}
|
||||
}
|
||||
+342
-52
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -44,16 +45,34 @@ var (
|
||||
sessionSaveLocks sync.Map
|
||||
ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
|
||||
ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
|
||||
errSessionFileLockHeld = errors.New("session file lock held")
|
||||
sessionWriterID = newSessionWriterID()
|
||||
// ErrSessionRecoveryDepthExceeded refuses a recovery fork whose parent is
|
||||
// already SessionRecoveryMaxDepth recovery forks deep. A chain that deep
|
||||
// means saves keep conflicting on branches this runtime itself created;
|
||||
// forking further multiplies session files without converging (#5993).
|
||||
ErrSessionRecoveryDepthExceeded = errors.New("session recovery chain depth exceeded")
|
||||
errSessionFileLockHeld = errors.New("session file lock held")
|
||||
sessionWriterID = newSessionWriterID()
|
||||
)
|
||||
|
||||
// SessionRecoveryMaxDepth bounds nested recovery forks: a normal session may
|
||||
// fork a recovery branch (depth 1), which may itself fork twice more under
|
||||
// genuine repeated incidents; past that the caller should stop forking and
|
||||
// write onto the branch it already owns.
|
||||
const SessionRecoveryMaxDepth = 3
|
||||
|
||||
type sessionPersistState struct {
|
||||
path string
|
||||
digest [sha256.Size]byte
|
||||
version uint64
|
||||
revision int64
|
||||
ok bool
|
||||
// revisionKnown marks revision as a real ledger value. It is false when
|
||||
// the baseline was established while the meta sidecar was unreadable
|
||||
// (torn or corrupt): the session must still open, but revision 0 must not
|
||||
// pose as a baseline or every honest on-disk revision would read as a
|
||||
// stale-runtime conflict. CAS checks fall back to digest+version until a
|
||||
// successful save re-learns the revision.
|
||||
revisionKnown bool
|
||||
ok bool
|
||||
}
|
||||
|
||||
type sessionSaveMode int
|
||||
@@ -73,6 +92,11 @@ type snapshotWriteDecision struct {
|
||||
// a lost suffix, or nothing decodable): the safe write shape is a full
|
||||
// rewrite that also compacts the log back to a healthy single event.
|
||||
repairLog bool
|
||||
// ledgerStale is set when the on-disk transcript already matches the
|
||||
// snapshot but the meta ledger still describes older content — the
|
||||
// aftermath of a save whose bytes landed and whose revision record then
|
||||
// failed. The up-to-date path must heal the ledger instead of skipping it.
|
||||
ledgerStale bool
|
||||
}
|
||||
|
||||
type SessionSnapshotConflictKind string
|
||||
@@ -160,11 +184,6 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
}
|
||||
msgs, version := s.snapshotWithVersion()
|
||||
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseRevision := int64(0)
|
||||
unlock := lockSessionSavePath(path)
|
||||
defer unlock()
|
||||
@@ -176,6 +195,16 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
return fmt.Errorf("lock session file: %w", err)
|
||||
}
|
||||
defer unlockFile()
|
||||
// Capture the snapshot only while holding the save locks. Concurrent
|
||||
// in-process savers (turn-end snapshot, periodic autosave, shutdown
|
||||
// snapshot) that captured before locking could land out of order: the
|
||||
// stalest capture written last would then read the newer transcript it
|
||||
// lost the race to as a bogus stale-prefix conflict.
|
||||
msgs, version, rewriteVersion := s.snapshotWithVersion()
|
||||
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
probe, err := probeSessionEventLog(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -202,7 +231,28 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
// other runtime resumed on this file and turning their next
|
||||
// legitimate save into a stale-runtime conflict. Skip the write and
|
||||
// adopt the current on-disk revision as this session's baseline.
|
||||
s.markPersisted(path, digest, version, decision.revision)
|
||||
if decision.ledgerStale {
|
||||
// ...unless the ledger never learned about this transcript: a
|
||||
// prior save landed its bytes and then failed to record the
|
||||
// revision. Same-content retries are exactly the "later save"
|
||||
// that failure deferred to, and skipping here would strand the
|
||||
// ledger on the old digest forever. Record now, reproducing
|
||||
// the state the interrupted save would have left.
|
||||
revision, err := recordSessionContentRevision(path, digest, decision.revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if probe.native {
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
// See the append path below: index loss must not fail a
|
||||
// save whose transcript and revision already landed.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
if decision.appendOnly && probe.native {
|
||||
@@ -232,9 +282,14 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
return err
|
||||
}
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
return err
|
||||
// The event index is only a listing accelerator; the transcript
|
||||
// and its revision are already durable above. Failing the save
|
||||
// here would skip markPersisted and leave the in-memory baseline
|
||||
// behind the disk state it just wrote, misreading the next save
|
||||
// as a stale-runtime conflict.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision)
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
baseRevision = decision.revision
|
||||
@@ -291,10 +346,12 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
}
|
||||
if probe.native {
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
return err
|
||||
// See the append path above: index loss must not fail a save whose
|
||||
// transcript and revision already landed.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision)
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -341,7 +398,7 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
|
||||
}
|
||||
return snapshotWriteDecision{}, err
|
||||
}
|
||||
currentRevision, _, err := sessionContentRevision(path)
|
||||
currentRevision, currentLedgerDigest, err := sessionContentRevision(path)
|
||||
if err != nil {
|
||||
return snapshotWriteDecision{}, err
|
||||
}
|
||||
@@ -351,10 +408,53 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
|
||||
if err != nil {
|
||||
return snapshotWriteDecision{}, err
|
||||
}
|
||||
// raw is the transcript as stored, before load-time normalization repaired
|
||||
// it; it equals existing when no repair ran. The prefix checks below must
|
||||
// be able to fall back to it: a mid-turn snapshot legitimately cuts an
|
||||
// assistant tool call from its still-running result, normalization then
|
||||
// fabricates a placeholder answer on load, and the live session's real
|
||||
// result collides with that placeholder — misreading a pure append as
|
||||
// divergence (and forking a bogus recovery branch).
|
||||
raw, rawDigest := existing, existingDigest
|
||||
rawDiffers := current.normalizedDirty && len(current.rawMessages) > 0
|
||||
if rawDiffers {
|
||||
raw = current.rawMessages
|
||||
if rawDigest, err = digestSessionMessages(raw); err != nil {
|
||||
return snapshotWriteDecision{}, err
|
||||
}
|
||||
}
|
||||
contentUnchanged := bytes.Equal(existingDigest[:], nextDigest[:])
|
||||
exactAppend := messagesHavePrefix(next, existing)
|
||||
if contentUnchanged || exactAppend || messagesHavePrefixWithCompatibleSystem(next, existing) {
|
||||
if baseState.ok && currentRevision != baseState.revision && !contentUnchanged {
|
||||
appendShaped := contentUnchanged || exactAppend || messagesHavePrefixWithCompatibleSystem(next, existing)
|
||||
repairPending := current.normalizedDirty
|
||||
if !appendShaped && rawDiffers {
|
||||
rawUnchanged := bytes.Equal(rawDigest[:], nextDigest[:])
|
||||
rawAppend := messagesHavePrefix(next, raw)
|
||||
if rawUnchanged || rawAppend || messagesHavePrefixWithCompatibleSystem(next, raw) {
|
||||
existing = raw
|
||||
contentUnchanged = rawUnchanged
|
||||
exactAppend = rawAppend
|
||||
appendShaped = true
|
||||
// The snapshot supersedes the repaired view — appending it lands
|
||||
// the real tool results where the placeholders were fabricated —
|
||||
// so no load-time repair is left to force a rewrite.
|
||||
repairPending = false
|
||||
}
|
||||
}
|
||||
if appendShaped {
|
||||
// An unknown-revision baseline (meta sidecar unreadable at load) cannot
|
||||
// vouch for revision equality; the digest/prefix checks above already
|
||||
// vouch for the content, so only a known baseline arms the CAS check.
|
||||
// Under an append-shaped write (at most a compatible leading-system
|
||||
// swap) a stale revision is ledger drift — a reset sidecar, a
|
||||
// same-content heal, or another runtime recording messages this
|
||||
// snapshot already contains — unless the transcript was rewound.
|
||||
// Locating the persisted baseline among the snapshot's prefixes and
|
||||
// requiring the disk transcript to still reach it tells the two apart:
|
||||
// drift keeps the baseline reachable, while a rewind cut below it and
|
||||
// appending would resurrect the suffix another runtime removed.
|
||||
if baseState.ok && baseState.revisionKnown && currentRevision != baseState.revision && !contentUnchanged &&
|
||||
!appendCoversPersistedBaseline(next, existing, baseState.digest) {
|
||||
return snapshotWriteDecision{}, snapshotConflict(path, existing, next, baseState.revision, currentRevision)
|
||||
}
|
||||
// A normalized-dirty load means LoadSession repaired the history on the
|
||||
@@ -364,19 +464,43 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
|
||||
// replayable prefix already matches this snapshot.
|
||||
decision := snapshotWriteDecision{
|
||||
revision: currentRevision,
|
||||
upToDate: contentUnchanged && !current.normalizedDirty && !current.eventLogDamaged,
|
||||
upToDate: contentUnchanged && !repairPending && !current.eventLogDamaged,
|
||||
repairLog: current.eventLogDamaged,
|
||||
}
|
||||
if exactAppend && !contentUnchanged && len(existing) < len(next) && !current.eventLogDamaged {
|
||||
// A ledger digest that describes different content than the transcript
|
||||
// on disk is the aftermath of a save whose bytes landed and whose
|
||||
// revision record then failed (crash or fail-closed record between the
|
||||
// two writes). Only a non-empty mismatch counts: a missing sidecar or
|
||||
// a legacy one without a digest is a legitimate state, and stamping it
|
||||
// here would bump revisions other runtimes still hold as baselines.
|
||||
if decision.upToDate && currentLedgerDigest != "" && currentLedgerDigest != digestString(nextDigest) {
|
||||
decision.ledgerStale = true
|
||||
}
|
||||
// An append is only chain-safe when existing measures the transcript
|
||||
// the event log actually replays. Under a pending load-time repair the
|
||||
// normalized view differs from the raw log, so an append event indexed
|
||||
// against it breaks the replay chain and orphans the appended suffix;
|
||||
// fall through to the full rewrite, which also persists the repair.
|
||||
if exactAppend && !contentUnchanged && len(existing) < len(next) && !current.eventLogDamaged && !repairPending {
|
||||
decision.appendOnly = true
|
||||
decision.appendFrom = len(existing)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
if allowOwnedRewrite && s.ownsPersistedState(path, existingDigest, currentRevision, nextVersion) {
|
||||
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
|
||||
if allowOwnedRewrite {
|
||||
owned := s.ownsPersistedState(path, existingDigest, currentRevision, currentLedgerDigest, nextVersion)
|
||||
if !owned && rawDiffers {
|
||||
// The persisted baseline describes the bytes this session wrote, so
|
||||
// a repaired view can never match it; ownership is judged against
|
||||
// the raw transcript.
|
||||
owned = s.ownsPersistedState(path, rawDigest, currentRevision, currentLedgerDigest, nextVersion)
|
||||
}
|
||||
if owned {
|
||||
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
|
||||
}
|
||||
}
|
||||
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
|
||||
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
|
||||
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
|
||||
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
|
||||
Path: path,
|
||||
Kind: SessionSnapshotConflictStalePrefix,
|
||||
@@ -416,7 +540,7 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
if originalPath == "" {
|
||||
return RecoveryBranchInfo{}, fmt.Errorf("empty original session path")
|
||||
}
|
||||
msgs, version := s.snapshotWithVersion()
|
||||
msgs, version, rewriteVersion := s.snapshotWithVersion()
|
||||
preview, turns := SessionPreviewFromMessages(msgs)
|
||||
if turns == 0 {
|
||||
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
|
||||
@@ -445,13 +569,45 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
if digestErr != nil {
|
||||
return RecoveryBranchInfo{}, digestErr
|
||||
}
|
||||
if bytes.Equal(existingDigest[:], digest[:]) ||
|
||||
covered := bytes.Equal(existingDigest[:], digest[:]) ||
|
||||
messagesHavePrefix(existing, msgs) ||
|
||||
messagesHavePrefixWithCompatibleSystem(existing, msgs) {
|
||||
messagesHavePrefixWithCompatibleSystem(existing, msgs)
|
||||
if !covered && current.normalizedDirty && len(current.rawMessages) > 0 {
|
||||
// Judge coverage against the pre-repair transcript too, for the
|
||||
// same reason as checkSnapshotWrite: load-time normalization can
|
||||
// reshape what is actually stored, and a recovery fork is only
|
||||
// warranted when the stored bytes themselves fail to cover this
|
||||
// snapshot.
|
||||
raw := current.rawMessages
|
||||
rawDigest, rawErr := digestSessionMessages(raw)
|
||||
if rawErr != nil {
|
||||
return RecoveryBranchInfo{}, rawErr
|
||||
}
|
||||
covered = bytes.Equal(rawDigest[:], digest[:]) ||
|
||||
messagesHavePrefix(raw, msgs) ||
|
||||
messagesHavePrefixWithCompatibleSystem(raw, msgs)
|
||||
}
|
||||
if covered {
|
||||
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse to deepen a runaway chain: forking FROM a branch that is already
|
||||
// at the depth cap only multiplies recovery files (#5993 reached 8 nested
|
||||
// levels). The caller falls back to force-writing the branch it owns.
|
||||
parentDepth := 0
|
||||
if parentMeta, ok, metaErr := LoadBranchMeta(originalPath); metaErr == nil && ok && parentMeta.Recovered {
|
||||
parentDepth = parentMeta.RecoveryDepth
|
||||
if parentDepth <= 0 {
|
||||
// Legacy recovery meta predating RecoveryDepth.
|
||||
parentDepth = 1
|
||||
}
|
||||
}
|
||||
if parentDepth >= SessionRecoveryMaxDepth {
|
||||
return RecoveryBranchInfo{}, fmt.Errorf("%w: %s is already %d recovery forks deep",
|
||||
ErrSessionRecoveryDepthExceeded, originalPath, parentDepth)
|
||||
}
|
||||
|
||||
recoveryPath := recoverySessionPath(originalPath, digest)
|
||||
unlockRecovery := lockSessionSavePath(recoveryPath)
|
||||
defer unlockRecovery()
|
||||
@@ -466,11 +622,11 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
return RecoveryBranchInfo{}, digestErr
|
||||
}
|
||||
if bytes.Equal(existingDigest[:], digest[:]) {
|
||||
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText)
|
||||
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText, parentDepth+1)
|
||||
if err != nil {
|
||||
return RecoveryBranchInfo{}, err
|
||||
}
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision)
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
|
||||
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Existing: true, Meta: meta, Preview: preview, Turns: turns}, nil
|
||||
}
|
||||
} else if loadErr != nil && !os.IsNotExist(loadErr) {
|
||||
@@ -495,18 +651,23 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
if err := writeSessionMessages(recoveryPath, msgs); err != nil {
|
||||
return RecoveryBranchInfo{}, err
|
||||
}
|
||||
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText)
|
||||
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText, parentDepth+1)
|
||||
if err != nil {
|
||||
return RecoveryBranchInfo{}, err
|
||||
}
|
||||
if err := writeSessionEventIndex(recoveryPath, msgs, digest, meta.Revision); err != nil {
|
||||
return RecoveryBranchInfo{}, err
|
||||
// The recovery transcript (log + checkpoint) and its meta are already
|
||||
// durable; the index is only a listing accelerator. Failing here would
|
||||
// discard a recovery that in fact succeeded and re-run the whole
|
||||
// conflict path on the next save.
|
||||
slog.Warn("session: keeping recovery branch after event index write failure",
|
||||
"path", recoveryPath, "err", err)
|
||||
}
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision)
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
|
||||
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Meta: meta, Preview: preview, Turns: turns}, nil
|
||||
}
|
||||
|
||||
func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions, preview string, turns int, digest string) (BranchMeta, error) {
|
||||
func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions, preview string, turns int, digest string, depth int) (BranchMeta, error) {
|
||||
meta := opts.BranchMeta
|
||||
meta.ID = BranchID(path)
|
||||
if strings.TrimSpace(meta.Name) == "" {
|
||||
@@ -523,6 +684,9 @@ func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions
|
||||
meta.Recovered = true
|
||||
meta.RecoveryReason = firstNonEmpty(strings.TrimSpace(opts.Reason), "session snapshot conflict")
|
||||
meta.RecoveryDigest = digest
|
||||
// Always stamped from the parent chain, never trusted from opts: callers
|
||||
// copy tab/session meta wholesale and would carry a stale depth.
|
||||
meta.RecoveryDepth = depth
|
||||
if meta.Revision == 0 {
|
||||
meta.Revision = 1
|
||||
}
|
||||
@@ -605,12 +769,33 @@ func firstNonEmpty(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, nextVersion uint64) bool {
|
||||
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool {
|
||||
state := s.persistState(path)
|
||||
return state.ok &&
|
||||
state.version <= nextVersion &&
|
||||
state.revision == existingRevision &&
|
||||
bytes.Equal(existingDigest[:], state.digest[:])
|
||||
if !state.ok || state.version > nextVersion || !bytes.Equal(existingDigest[:], state.digest[:]) {
|
||||
return false
|
||||
}
|
||||
// An unknown-revision baseline still owns the transcript it loaded — the
|
||||
// digest+version match proves it. Requiring revision equality here would
|
||||
// make every rewrite from such a baseline a permanent conflict, because
|
||||
// the revision can only be re-learned by a successful save.
|
||||
// A disk ledger with no recorded revision is the mirror case: recorded
|
||||
// revisions start at 1, so revision 0 means the sidecar was deleted or
|
||||
// rebuilt by a listing-only writer after this session's save. An absent
|
||||
// claim cannot revoke the ownership the digest+version match proves.
|
||||
if !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision {
|
||||
return true
|
||||
}
|
||||
// A foreign revision stamp whose recorded digest still describes these
|
||||
// exact bytes (a same-content heal or no-op record by another runtime)
|
||||
// vouches for no content of its own: the transcript is byte-for-byte what
|
||||
// this session last persisted, so rewriting it destroys nothing of
|
||||
// theirs — at worst the conflict moves to the stamper's next divergent
|
||||
// save, where its in-memory history forks a recovery branch as usual.
|
||||
// A stamp that disagrees with the on-disk transcript (or a legacy stamp
|
||||
// with no digest) keeps revoking ownership: that is the aftermath of a
|
||||
// save whose bytes and record split, the bytes cannot be attributed, and
|
||||
// only the conservative conflict path preserves both sides.
|
||||
return existingLedgerDigest == digestString(existingDigest)
|
||||
}
|
||||
|
||||
func (s *Session) persistState(path string) sessionPersistState {
|
||||
@@ -623,22 +808,46 @@ func (s *Session) persistState(path string) sessionPersistState {
|
||||
return sessionPersistState{}
|
||||
}
|
||||
|
||||
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64) {
|
||||
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int) {
|
||||
s.setPersistedBaseline(path, digest, version, revision, true, rewriteVersion)
|
||||
}
|
||||
|
||||
// markPersistedRevisionUnknown records a baseline whose ledger revision could
|
||||
// not be learned because the meta sidecar was unreadable. The digest and
|
||||
// version still anchor ownership checks; revision-based CAS stays disarmed
|
||||
// until a successful save records the real revision via markPersisted.
|
||||
func (s *Session) markPersistedRevisionUnknown(path string, digest [sha256.Size]byte, version uint64, rewriteVersion int) {
|
||||
s.setPersistedBaseline(path, digest, version, 0, false, rewriteVersion)
|
||||
}
|
||||
|
||||
func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, version uint64, revision int64, revisionKnown bool, rewriteVersion int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.persisted = sessionPersistState{
|
||||
path: canonicalSessionSavePath(path),
|
||||
digest: digest,
|
||||
version: version,
|
||||
revision: revision,
|
||||
ok: true,
|
||||
path: canonicalSessionSavePath(path),
|
||||
digest: digest,
|
||||
version: version,
|
||||
revision: revision,
|
||||
revisionKnown: revisionKnown,
|
||||
ok: true,
|
||||
}
|
||||
// rewriteVersion was captured together with the persisted snapshot; only
|
||||
// move forward so a slower save that captured earlier cannot roll the
|
||||
// baseline back below a rewrite a faster save already persisted.
|
||||
if rewriteVersion > s.persistedRewriteVersion {
|
||||
s.persistedRewriteVersion = rewriteVersion
|
||||
}
|
||||
}
|
||||
|
||||
// sessionContentRevision reads the CAS ledger (revision + content digest) from
|
||||
// the branch-meta sidecar. A missing sidecar is revision 0 — a session that
|
||||
// has never recorded one. An unreadable sidecar is an error: reporting it as
|
||||
// revision 0 would desync every runtime baseline from the ledger and turn the
|
||||
// next honest save into a bogus conflict (and a recovery branch).
|
||||
func sessionContentRevision(path string) (int64, string, error) {
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
meta, ok, err := loadBranchMetaRetry(path)
|
||||
if err != nil {
|
||||
return 0, "", nil
|
||||
return 0, "", err
|
||||
}
|
||||
if !ok {
|
||||
return 0, "", nil
|
||||
@@ -647,9 +856,14 @@ func sessionContentRevision(path string) (int64, string, error) {
|
||||
}
|
||||
|
||||
func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRevision int64) (int64, error) {
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
meta, ok, err := loadBranchMetaRetry(path)
|
||||
if err != nil {
|
||||
return baseRevision, nil
|
||||
// Fail the save instead of rebuilding the ledger from a bad read: the
|
||||
// transcript bytes already landed, so a later save can record the
|
||||
// revision once the sidecar reads cleanly again — a content-bearing
|
||||
// save lands here again, and a same-content retry heals through the
|
||||
// up-to-date ledgerStale path in save().
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
meta = BranchMeta{ID: BranchID(path)}
|
||||
@@ -663,7 +877,7 @@ func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRev
|
||||
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stored, ok, err := LoadBranchMeta(path)
|
||||
stored, ok, err := loadBranchMetaRetry(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -742,6 +956,49 @@ func messagesHavePrefix(full, prefix []provider.Message) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// messagesPrefixDigestDepth returns the number of leading messages of msgs
|
||||
// whose storage digest equals target, or -1 when no prefix matches. The
|
||||
// digest accumulates exactly like digestAndSizeSessionMessages, so a match at
|
||||
// depth k means msgs[:k] is byte-for-byte the transcript that produced target.
|
||||
func messagesPrefixDigestDepth(msgs []provider.Message, target [sha256.Size]byte) int {
|
||||
h := sha256.New()
|
||||
sum := make([]byte, 0, sha256.Size)
|
||||
for i, m := range msgs {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
h.Write(b)
|
||||
h.Write([]byte{'\n'})
|
||||
sum = h.Sum(sum[:0])
|
||||
if bytes.Equal(sum, target[:]) {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// appendCoversPersistedBaseline reports whether an append-shaped write (disk
|
||||
// transcript a prefix of next, modulo a compatible leading-system swap) still
|
||||
// covers everything this session ever persisted: the baseline digest must be
|
||||
// reachable as a prefix of the pending snapshot, and the disk transcript must
|
||||
// still extend at least to that depth. A shorter disk transcript means some
|
||||
// other runtime deliberately rewound below the baseline — appending over it
|
||||
// would resurrect the removed suffix, so the caller must conflict instead.
|
||||
func appendCoversPersistedBaseline(next, existing []provider.Message, baseDigest [sha256.Size]byte) bool {
|
||||
depth := messagesPrefixDigestDepth(next, baseDigest)
|
||||
if depth < 0 && len(next) > 0 && len(existing) > 0 &&
|
||||
next[0].Role == provider.RoleSystem && existing[0].Role == provider.RoleSystem &&
|
||||
!messagesEqualForStorage(next[0], existing[0]) {
|
||||
// A resume that swapped the system prompt persisted its baseline with
|
||||
// the previous system message — the one still on disk. Re-anchor the
|
||||
// search on that message so the swap alone doesn't hide the baseline.
|
||||
variant := append([]provider.Message{existing[0]}, next[1:]...)
|
||||
depth = messagesPrefixDigestDepth(variant, baseDigest)
|
||||
}
|
||||
return depth >= 0 && len(existing) >= depth
|
||||
}
|
||||
|
||||
func messagesHavePrefixWithCompatibleSystem(full, prefix []provider.Message) bool {
|
||||
full = messagesWithoutLeadingSystem(full)
|
||||
prefix = messagesWithoutLeadingSystem(prefix)
|
||||
@@ -813,6 +1070,20 @@ func canonicalSessionSavePath(path string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// CanonicalSessionPath is the identity key of a session path: cleaned,
|
||||
// absolute, and case-folded on Windows, matching the key form used by the
|
||||
// lease registry and the save-path locks. Any runtime bookkeeping that
|
||||
// compares or maps session paths (desktop tabs, detached runtimes) must use
|
||||
// this exact form, or the same file splits into distinct keys — e.g.
|
||||
// `C:\Users\...` vs the lease's lowercased `c:\users\...`. Empty input stays
|
||||
// empty instead of resolving to the working directory.
|
||||
func CanonicalSessionPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
return canonicalSessionSavePath(path)
|
||||
}
|
||||
|
||||
// LoadSession reads a saved session into a fresh Session value. New sessions
|
||||
// replay the append-only event log; legacy sessions without an event log fall
|
||||
// back to the compatibility .jsonl checkpoint. A damaged log is replayed to its
|
||||
@@ -838,14 +1109,30 @@ func LoadSession(path string) (*Session, error) {
|
||||
normalized := NormalizeSession(s.Messages)
|
||||
if len(normalized) != len(s.Messages) || (len(s.Messages) > 0 && &normalized[0] != &s.Messages[0]) {
|
||||
s.normalizedDirty = true
|
||||
// Keep the pre-repair transcript: checkSnapshotWrite must be able to
|
||||
// recognize a snapshot that extends the bytes actually on disk, which
|
||||
// the repaired view no longer represents (an interrupted tool turn
|
||||
// gets a placeholder result fabricated here that the live session
|
||||
// answered for real).
|
||||
s.rawMessages = msgs
|
||||
}
|
||||
s.Messages = normalized
|
||||
if digest, err := digestSessionMessages(s.Messages); err == nil {
|
||||
revision := int64(0)
|
||||
if meta, ok, metaErr := LoadBranchMeta(path); metaErr == nil && ok {
|
||||
revision = meta.Revision
|
||||
if meta, ok, metaErr := loadBranchMetaRetry(path); metaErr != nil {
|
||||
// The sidecar exists but is unreadable even after retries (torn or
|
||||
// corrupt). The session must still open, but revision 0 must not
|
||||
// pose as a real baseline: the next save would misread the honest
|
||||
// on-disk revision as another runtime's write and fork a recovery
|
||||
// branch. Anchor the baseline on digest+version only until a
|
||||
// successful save re-learns the revision.
|
||||
s.markPersistedRevisionUnknown(path, digest, s.version, s.rewriteVersion)
|
||||
} else {
|
||||
revision := int64(0)
|
||||
if ok {
|
||||
revision = meta.Revision
|
||||
}
|
||||
s.markPersisted(path, digest, s.version, revision, s.rewriteVersion)
|
||||
}
|
||||
s.markPersisted(path, digest, s.version, revision)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -1190,7 +1477,7 @@ func reconcileOverlongSessionFilenames(dir string) error {
|
||||
renamed := map[string]string{} // old branch ID -> new branch ID
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, guardianSidecarSuffix) {
|
||||
if e.IsDir() || !store.IsSessionTranscriptName(name) {
|
||||
continue
|
||||
}
|
||||
if len(name) <= maxSessionBasenameBytes {
|
||||
@@ -1314,6 +1601,9 @@ func migrateSessionSidecars(oldPath, newPath, newID string) error {
|
||||
}
|
||||
for _, pair := range [][2]string{
|
||||
{store.SessionGoalState(oldPath), store.SessionGoalState(newPath)},
|
||||
{store.SessionEventLog(oldPath), store.SessionEventLog(newPath)},
|
||||
{store.SessionEventIndex(oldPath), store.SessionEventIndex(newPath)},
|
||||
{store.SessionConflictLog(oldPath), store.SessionConflictLog(newPath)},
|
||||
{store.SessionCheckpointDir(oldPath), store.SessionCheckpointDir(newPath)},
|
||||
{store.SessionJobsDir(oldPath), store.SessionJobsDir(newPath)},
|
||||
} {
|
||||
@@ -1339,7 +1629,7 @@ func reparentSessionBranches(dir string, renamed map[string]string) error {
|
||||
var errs []error
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, guardianSidecarSuffix) {
|
||||
if e.IsDir() || !store.IsSessionTranscriptName(name) {
|
||||
continue
|
||||
}
|
||||
if len(name)+len(".meta") > nameMaxBytes {
|
||||
|
||||
+697
-2
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// touch sets a file's mtime to t. Used by the listing-order test so it
|
||||
@@ -144,6 +145,337 @@ func TestSaveSnapshotAllowsAppendFromDiskPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSnapshotAppendsAcrossInterruptedToolCallTail is the mid-turn autosave
|
||||
// shape from the field: a snapshot lands between an assistant tool call and its
|
||||
// still-running result, so the transcript on disk ends with a dangling call
|
||||
// that LoadSession answers with a fabricated placeholder. The live session then
|
||||
// records the real result and keeps going. The next snapshot is a pure append
|
||||
// over the bytes on disk and must land as one — not collide with the
|
||||
// placeholder, misread the turn as divergence, and fork a recovery branch.
|
||||
func TestSaveSnapshotAppendsAcrossInterruptedToolCallTail(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant, Content: "Running it.",
|
||||
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
||||
})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Build passed."})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot after tool result: %v", err)
|
||||
}
|
||||
|
||||
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("replay event log: %v", err)
|
||||
}
|
||||
if replay.damaged {
|
||||
t.Fatal("event log damaged after appending over an interrupted tool tail")
|
||||
}
|
||||
if replay.records != 2 {
|
||||
t.Fatalf("event log records = %d, want 2 (bootstrap replace + append)", replay.records)
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 5 {
|
||||
t.Fatalf("message count after append snapshot = %d, want 5", got)
|
||||
}
|
||||
if got := loaded.Messages[3].Content; got != "ok" {
|
||||
t.Fatalf("tool result after round-trip = %q, want %q", got, "ok")
|
||||
}
|
||||
if loaded.normalizedDirty {
|
||||
t.Fatal("transcript still needs repair after appending the real tool result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail covers the
|
||||
// same raw-prefix fallback when a multi-call assistant turn already has some
|
||||
// tool results on disk. LoadSession fabricates placeholders only for the still
|
||||
// unanswered calls; the live session later appends the real remaining results.
|
||||
func TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect and test"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant, Content: "I will run a few checks.",
|
||||
ToolCalls: []provider.ToolCall{
|
||||
{ID: "call_read", Name: "read_file", Arguments: `{"path":"main.go"}`},
|
||||
{ID: "call_test", Name: "bash", Arguments: `{"cmd":"go test ./..."}`},
|
||||
{ID: "call_status", Name: "bash", Arguments: `{"cmd":"git status --short"}`},
|
||||
},
|
||||
})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_read", Content: "package main"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("partial multi-tool SaveSnapshot: %v", err)
|
||||
}
|
||||
|
||||
loadedPartial, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession partial: %v", err)
|
||||
}
|
||||
if !loadedPartial.normalizedDirty {
|
||||
t.Fatal("partial multi-tool load should need placeholder repair")
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_test", Content: "ok"})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_status", Content: "clean"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "All checks passed."})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot after remaining multi-tool results: %v", err)
|
||||
}
|
||||
|
||||
events := readSessionEventsForTest(t, path)
|
||||
if len(events) != 2 || events[1].Type != sessionEventTypeAppend {
|
||||
t.Fatalf("events after multi-tool append = %+v, want trailing append", events)
|
||||
}
|
||||
if events[1].MessageIndex != 4 || len(events[1].Messages) != 3 {
|
||||
t.Fatalf("multi-tool append event index=%d len=%d, want index 4 len 3", events[1].MessageIndex, len(events[1].Messages))
|
||||
}
|
||||
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("replay event log: %v", err)
|
||||
}
|
||||
if replay.damaged {
|
||||
t.Fatal("event log damaged after appending remaining multi-tool results")
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession final: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 7 {
|
||||
t.Fatalf("message count after multi-tool append = %d, want 7", got)
|
||||
}
|
||||
if got := loaded.Messages[4].Content; got != "ok" {
|
||||
t.Fatalf("second tool result after round-trip = %q, want ok", got)
|
||||
}
|
||||
if got := loaded.Messages[5].Content; got != "clean" {
|
||||
t.Fatalf("third tool result after round-trip = %q, want clean", got)
|
||||
}
|
||||
if loaded.normalizedDirty {
|
||||
t.Fatal("multi-tool transcript still needs repair after real results landed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp covers the turn that
|
||||
// stays interrupted (cancel, crash recovery with nothing new in memory):
|
||||
// re-snapshotting the exact bytes on disk must be a no-op, not a stale-prefix
|
||||
// conflict against the placeholder the load-time repair fabricated.
|
||||
func TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant, Content: "Running it.",
|
||||
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
||||
})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
||||
}
|
||||
logBefore, err := os.ReadFile(SessionEventLogPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile event log: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot unchanged: %v", err)
|
||||
}
|
||||
logAfter, err := os.ReadFile(SessionEventLogPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile event log after no-op snapshot: %v", err)
|
||||
}
|
||||
if string(logBefore) != string(logAfter) {
|
||||
t.Fatal("no-op snapshot rewrote the event log")
|
||||
}
|
||||
if revision, _, err := sessionContentRevision(path); err != nil || revision != 1 {
|
||||
t.Fatalf("revision after no-op snapshot = %d (err %v), want 1", revision, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveRewriteOwnedAcrossInterruptedToolCallTail: compaction rewrites the
|
||||
// in-memory history while the transcript on disk still ends with the dangling
|
||||
// call a mid-turn snapshot left behind. Ownership is anchored on the raw bytes
|
||||
// this session wrote; the placeholder fabricated on load must not revoke it.
|
||||
func TestSaveRewriteOwnedAcrossInterruptedToolCallTail(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant, Content: "Running it.",
|
||||
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
||||
})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
||||
}
|
||||
|
||||
s.Replace([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "[compacted] run the build"},
|
||||
})
|
||||
if err := s.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite after compaction: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 2 {
|
||||
t.Fatalf("message count after owned rewrite = %d, want 2", got)
|
||||
}
|
||||
if got := loaded.Messages[1].Content; got != "[compacted] run the build" {
|
||||
t.Fatalf("compacted message after round-trip = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable: a session resumed
|
||||
// from an interrupted tool tail carries the load-time repair in memory, so its
|
||||
// transcript is one message longer than what the event log replays. The next
|
||||
// snapshot must not take the append shortcut with that inflated index — the
|
||||
// chain-broken append event would be discarded on replay, silently dropping
|
||||
// the whole new turn from disk. It must fall back to a full rewrite that
|
||||
// persists the repair and the new turn together.
|
||||
func TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant, Content: "Running it.",
|
||||
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
||||
})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
||||
}
|
||||
|
||||
// Crash + reopen: the resume load answers the dangling call with a
|
||||
// placeholder, then the user runs another turn.
|
||||
resumed, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession resume: %v", err)
|
||||
}
|
||||
if !resumed.normalizedDirty {
|
||||
t.Fatal("resume load should carry a pending repair for the dangling call")
|
||||
}
|
||||
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "try again"})
|
||||
resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := resumed.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot after dirty resume: %v", err)
|
||||
}
|
||||
|
||||
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("replay event log: %v", err)
|
||||
}
|
||||
if replay.damaged {
|
||||
t.Fatal("event log chain broken by the post-resume snapshot")
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession reload: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 6 {
|
||||
t.Fatalf("message count after post-resume snapshot = %d, want 6", got)
|
||||
}
|
||||
if got := loaded.Messages[3].ToolCallID; got != "call_1" {
|
||||
t.Fatalf("placeholder tool result not persisted; message 3 tool_call_id = %q", got)
|
||||
}
|
||||
if got := loaded.Messages[5].Content; got != "done" {
|
||||
t.Fatalf("new turn after round-trip = %q, want %q", got, "done")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair covers a
|
||||
// same-length load-time repair: truncated tool-call JSON is fixed in memory, but
|
||||
// appending against the repaired view would leave the broken arguments on disk.
|
||||
// The snapshot must rewrite so the repair and new turn persist together.
|
||||
func TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run tests"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant,
|
||||
Content: "Running tests.",
|
||||
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"go test ./...`}},
|
||||
})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot with truncated args: %v", err)
|
||||
}
|
||||
|
||||
resumed, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession resume: %v", err)
|
||||
}
|
||||
if !resumed.normalizedDirty {
|
||||
t.Fatal("resume load should carry a pending repair for truncated tool arguments")
|
||||
}
|
||||
const repairedArgs = `{"cmd":"go test ./..."}`
|
||||
if got := resumed.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
|
||||
t.Fatalf("repaired args on resume = %q, want %q", got, repairedArgs)
|
||||
}
|
||||
|
||||
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "summarize"})
|
||||
resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "Tests passed."})
|
||||
if err := resumed.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot after truncated-args resume: %v", err)
|
||||
}
|
||||
|
||||
events := readSessionEventsForTest(t, path)
|
||||
if len(events) != 2 || events[1].Type != sessionEventTypeReplace || events[1].Reason != "snapshot" {
|
||||
t.Fatalf("events after truncated-args repair = %+v, want trailing snapshot replace", events)
|
||||
}
|
||||
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("replay event log: %v", err)
|
||||
}
|
||||
if replay.damaged {
|
||||
t.Fatal("event log damaged after truncated-args repair rewrite")
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession reload: %v", err)
|
||||
}
|
||||
if loaded.normalizedDirty {
|
||||
t.Fatal("truncated-args repair was not persisted")
|
||||
}
|
||||
if got := loaded.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
|
||||
t.Fatalf("persisted args = %q, want %q", got, repairedArgs)
|
||||
}
|
||||
if got := loaded.Messages[5].Content; got != "Tests passed." {
|
||||
t.Fatalf("new turn after round-trip = %q, want %q", got, "Tests passed.")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot: the
|
||||
// recovery-needed check must also judge coverage against the pre-repair bytes.
|
||||
// Here the stored transcript equals the snapshot exactly, but normalization
|
||||
// backfills an empty tool-call name on load; that repair must not make the
|
||||
// disk look like it fails to cover the snapshot and fork a pointless recovery.
|
||||
func TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleAssistant, Content: "Running it.",
|
||||
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "", Arguments: `{"cmd":"make"}`}},
|
||||
})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
_, err := s.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
||||
if !errors.Is(err, ErrSessionRecoveryNotNeeded) {
|
||||
t.Fatalf("SaveRecoveryBranch err = %v, want ErrSessionRecoveryNotNeeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSnapshotAppendsWithoutReplacingPrefixFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
base := NewSession("sys")
|
||||
@@ -444,6 +776,176 @@ func TestSaveSnapshotSameContentByOtherRuntimeKeepsClonedBaselineWritable(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
staleBaseline := s.persistState(path)
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot prefix append: %v", err)
|
||||
}
|
||||
prefixMeta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
|
||||
}
|
||||
if prefixMeta.Revision == staleBaseline.revision {
|
||||
t.Fatalf("prefix revision did not advance: %d", prefixMeta.Revision)
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
|
||||
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot exact append from stale revision baseline: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession appended: %v", err)
|
||||
}
|
||||
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
|
||||
t.Fatalf("tail after stale-baseline append = %q, want two", got)
|
||||
}
|
||||
advancedMeta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
||||
}
|
||||
if advancedMeta.Revision != prefixMeta.Revision+1 {
|
||||
t.Fatalf("revision after stale-baseline append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after stale-baseline append = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys v1")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
staleBaseline := s.persistState(path)
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot prefix append: %v", err)
|
||||
}
|
||||
prefixMeta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
// A resume swapped the system prompt, then the turn appended a message —
|
||||
// while the persistence baseline still points at the first save.
|
||||
msgs := s.Snapshot()
|
||||
msgs[0] = provider.Message{Role: provider.RoleSystem, Content: "sys v2"}
|
||||
msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "two"})
|
||||
s.Replace(msgs)
|
||||
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot compatible-system append from stale baseline: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession appended: %v", err)
|
||||
}
|
||||
if got := loaded.Messages[0].Content; got != "sys v2" {
|
||||
t.Fatalf("system after compatible append = %q, want sys v2", got)
|
||||
}
|
||||
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
|
||||
t.Fatalf("tail after compatible append = %q, want two", got)
|
||||
}
|
||||
advancedMeta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
||||
}
|
||||
if advancedMeta.Revision != prefixMeta.Revision+1 {
|
||||
t.Fatalf("revision after compatible append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after compatible append = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSnapshotRefusesStaleBaselineAppendOverRewoundTranscript(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot extend: %v", err)
|
||||
}
|
||||
|
||||
// Another runtime rewinds the transcript below this session's baseline
|
||||
// (e.g. a cancelled turn truncated the partial assistant reply).
|
||||
other, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession other: %v", err)
|
||||
}
|
||||
other.Replace(other.Snapshot()[:2])
|
||||
if err := other.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite rewind: %v", err)
|
||||
}
|
||||
|
||||
// Appending from the stale baseline would resurrect the rewound suffix.
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
|
||||
if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("SaveSnapshot over rewound transcript err = %v, want ErrSessionSnapshotConflict", err)
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession after refused append: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 2 {
|
||||
t.Fatalf("messages after refused append = %d, want rewound 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRewriteAllowsOwnedRewriteAfterLedgerReset(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial turn"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
// The meta sidecar (revision ledger) is lost — e.g. swept by a cleanup
|
||||
// that deleted session-adjacent files. The transcript itself is intact.
|
||||
if err := os.Remove(BranchMetaPath(path)); err != nil {
|
||||
t.Fatalf("remove sidecar: %v", err)
|
||||
}
|
||||
|
||||
// A cancelled turn strips the partial reply and flushes via SaveRewrite.
|
||||
msgs := s.Snapshot()
|
||||
s.Replace(msgs[:len(msgs)-1])
|
||||
if err := s.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite after ledger reset: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession truncated: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 2 {
|
||||
t.Fatalf("messages after owned rewrite = %d, want 2", got)
|
||||
}
|
||||
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "first" {
|
||||
t.Fatalf("tail after owned rewrite = %q, want first", got)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSnapshotStillPersistsNormalizedRepair(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
mal := NewSession("sys")
|
||||
@@ -527,7 +1029,7 @@ func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
|
||||
func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
base := NewSession("sys")
|
||||
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
@@ -540,6 +1042,9 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession stale: %v", err)
|
||||
}
|
||||
// Another runtime healed the ledger over identical content: the revision
|
||||
// advanced under a foreign writer id, but the recorded digest still
|
||||
// describes the exact bytes this session loaded and owns.
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
@@ -550,13 +1055,67 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
|
||||
t.Fatalf("bump revision: %v", err)
|
||||
}
|
||||
|
||||
stale.Replace([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "summarized first"},
|
||||
})
|
||||
if err := stale.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite over same-content stamp: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
|
||||
t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
|
||||
}
|
||||
advanced, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
||||
}
|
||||
if advanced.Revision != meta.Revision+1 {
|
||||
t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
base := NewSession("sys")
|
||||
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := base.Save(path); err != nil {
|
||||
t.Fatalf("Save base: %v", err)
|
||||
}
|
||||
|
||||
stale, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession stale: %v", err)
|
||||
}
|
||||
// A foreign stamp whose digest disagrees with the on-disk transcript is
|
||||
// the aftermath of a save whose bytes and record split — the transcript
|
||||
// cannot be attributed, so the rewrite must fall to the conflict path.
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
meta.Revision++
|
||||
meta.WriterID = "other-writer"
|
||||
meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
t.Fatalf("stamp foreign digest: %v", err)
|
||||
}
|
||||
|
||||
stale.Replace([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "summarized first"},
|
||||
})
|
||||
err = stale.SaveRewrite(path)
|
||||
if !errors.Is(err, ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("SaveRewrite revision conflict err = %v, want ErrSessionSnapshotConflict", err)
|
||||
t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
|
||||
}
|
||||
var conflict *SessionSnapshotConflictError
|
||||
if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {
|
||||
@@ -798,6 +1357,86 @@ func TestSaveRecoveryBranchSkipsPureStalePrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func divergedSessionPair(t *testing.T, dir, name string) (string, *Session) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
current := NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(path); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
stale := NewSession("sys")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
|
||||
return path, stale
|
||||
}
|
||||
|
||||
func stampRecoveryMeta(t *testing.T, path string, depth int) {
|
||||
t.Helper()
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
meta.Recovered = true
|
||||
meta.RecoveryDepth = depth
|
||||
if err := SaveBranchMeta(path, meta); err != nil {
|
||||
t.Fatalf("SaveBranchMeta: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRecoveryBranchStampsAndCapsChainDepth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Forking from a normal session stamps depth 1.
|
||||
path, stale := divergedSessionPair(t, dir, "session.jsonl")
|
||||
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
if info.Meta.RecoveryDepth != 1 {
|
||||
t.Fatalf("first fork depth = %d, want 1", info.Meta.RecoveryDepth)
|
||||
}
|
||||
|
||||
// Forking from a recovery branch increments the chain depth.
|
||||
deeper, staleDeeper := divergedSessionPair(t, dir, "deeper.jsonl")
|
||||
stampRecoveryMeta(t, deeper, 1)
|
||||
info, err = staleDeeper.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: deeper})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch from depth 1: %v", err)
|
||||
}
|
||||
if info.Meta.RecoveryDepth != 2 {
|
||||
t.Fatalf("nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
|
||||
}
|
||||
|
||||
// A legacy recovery meta without the depth field counts as depth 1.
|
||||
legacy, staleLegacy := divergedSessionPair(t, dir, "legacy.jsonl")
|
||||
stampRecoveryMeta(t, legacy, 0)
|
||||
info, err = staleLegacy.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: legacy})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch from legacy recovery: %v", err)
|
||||
}
|
||||
if info.Meta.RecoveryDepth != 2 {
|
||||
t.Fatalf("legacy nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
|
||||
}
|
||||
|
||||
// A parent at the cap refuses to fork deeper.
|
||||
capped, staleCapped := divergedSessionPair(t, dir, "capped.jsonl")
|
||||
stampRecoveryMeta(t, capped, SessionRecoveryMaxDepth)
|
||||
if _, err := staleCapped.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: capped}); !errors.Is(err, ErrSessionRecoveryDepthExceeded) {
|
||||
t.Fatalf("SaveRecoveryBranch at cap err = %v, want ErrSessionRecoveryDepthExceeded", err)
|
||||
}
|
||||
forks, err := filepath.Glob(filepath.Join(dir, "capped-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
if len(forks) != 0 {
|
||||
t.Fatalf("capped parent still forked: %v", forks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRecoveryBranchDedupesByDigest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
current := NewSession("sys")
|
||||
@@ -1372,3 +2011,59 @@ func readSessionEventsForTest(t *testing.T, path string) []sessionEventRecord {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestReconcileOverlongMigratesDiagnosticSidecars pins two halves of one fix:
|
||||
// overlong-name reconciliation must skip .events.jsonl / .conflicts.jsonl
|
||||
// sidecars instead of renaming each into a fake session with fabricated meta,
|
||||
// and the owning transcript's rename must carry those sidecars along instead
|
||||
// of orphaning them under the retired stem.
|
||||
func TestReconcileOverlongMigratesDiagnosticSidecars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 236-byte transcript name: past the 224 reconcile bound, while its
|
||||
// .events.jsonl (243) and .conflicts.jsonl (246) still fit under 255 —
|
||||
// exactly the window where the old suffix filter mistook them for
|
||||
// overlong sessions.
|
||||
id := strings.Repeat("s", 230)
|
||||
oldPath := filepath.Join(dir, id+".jsonl")
|
||||
content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
|
||||
if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionEventLog(oldPath),
|
||||
[]byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"hello"}]}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionConflictLog(oldPath),
|
||||
[]byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ReconcileSessionSidecars(dir); err != nil {
|
||||
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(dir, recoveryParentStem(id)+".jsonl")
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Fatalf("renamed transcript missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(newPath)); err != nil {
|
||||
t.Fatalf("event log not migrated with the rename: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionConflictLog(newPath)); err != nil {
|
||||
t.Fatalf("conflict log not migrated with the rename: %v", err)
|
||||
}
|
||||
for _, gone := range []string{oldPath, store.SessionEventLog(oldPath), store.SessionConflictLog(oldPath)} {
|
||||
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s still present under the retired stem (err=%v)", filepath.Base(gone), err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if store.IsSessionTranscriptName(e.Name()) && e.Name() != filepath.Base(newPath) {
|
||||
t.Fatalf("reconcile fabricated an extra session from a sidecar: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-16
@@ -18,7 +18,15 @@ type Session struct {
|
||||
Messages []provider.Message
|
||||
version uint64
|
||||
rewriteVersion int // bumped each time the log is rewritten (compact/fold)
|
||||
persisted sessionPersistState
|
||||
// persistedRewriteVersion is the highest rewriteVersion whose transcript
|
||||
// has fully reached disk. It lives on the Session — not on the controller
|
||||
// — so swapping session objects can never orphan or misattribute the
|
||||
// baseline: NeedsRewriteSave always compares a session against its own
|
||||
// save history. Save paths advance it under s.mu with the rewriteVersion
|
||||
// captured alongside the message snapshot, never a re-read one, so a
|
||||
// compaction landing mid-save stays unpersisted.
|
||||
persistedRewriteVersion int
|
||||
persisted sessionPersistState
|
||||
// normalizedDirty is set when LoadSession repaired the history on the way in
|
||||
// (empty tool-call names, dangling calls, truncated args, …). The repair
|
||||
// already lives in Messages, so the next Save persists it automatically as
|
||||
@@ -29,6 +37,12 @@ type Session struct {
|
||||
// or corrupt and returned the replayable prefix (or the .jsonl checkpoint).
|
||||
// The next save heals the log with a rewrite-and-compact.
|
||||
eventLogDamaged bool
|
||||
// rawMessages preserves the pre-normalization transcript when the load-time
|
||||
// repairs changed it (normalizedDirty). It is only meaningful on a freshly
|
||||
// loaded Session: checkSnapshotWrite compares a pending snapshot against
|
||||
// what is actually on disk, and the repaired view no longer represents
|
||||
// those bytes — a session that kept running extends the raw transcript.
|
||||
rawMessages []provider.Message
|
||||
}
|
||||
|
||||
// NewSession initializes a session with an optional system prompt.
|
||||
@@ -61,7 +75,7 @@ func (s *Session) Replace(msgs []provider.Message) {
|
||||
// while a turn appends. Frontends (History, Save) use it instead of touching the
|
||||
// live slice.
|
||||
func (s *Session) Snapshot() []provider.Message {
|
||||
msgs, _ := s.snapshotWithVersion()
|
||||
msgs, _, _ := s.snapshotWithVersion()
|
||||
return msgs
|
||||
}
|
||||
|
||||
@@ -91,12 +105,13 @@ func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
|
||||
version++
|
||||
}
|
||||
return &Session{
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persistedRewriteVersion: s.persistedRewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,19 +133,24 @@ func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Sessi
|
||||
version++
|
||||
}
|
||||
return &Session{
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persistedRewriteVersion: s.persistedRewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64) {
|
||||
// snapshotWithVersion returns the messages together with the version and
|
||||
// rewriteVersion they were captured under, in one lock window: save paths
|
||||
// persist exactly this rewriteVersion as the new baseline, so a rewrite that
|
||||
// lands after the capture cannot be misrecorded as saved.
|
||||
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]provider.Message(nil), s.Messages...), s.version
|
||||
return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
|
||||
}
|
||||
|
||||
// RewriteVersion returns the current rewrite version.
|
||||
@@ -140,6 +160,16 @@ func (s *Session) RewriteVersion() int {
|
||||
return s.rewriteVersion
|
||||
}
|
||||
|
||||
// NeedsRewriteSave reports whether the history has been rewritten in memory
|
||||
// (compaction, prune) since the last successful full save of this session.
|
||||
// Snapshot paths use it to decide that the next write must be an owned
|
||||
// rewrite instead of an append.
|
||||
func (s *Session) NeedsRewriteSave() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.rewriteVersion > s.persistedRewriteVersion
|
||||
}
|
||||
|
||||
// IncrementRewrite bumps the rewrite version by 1.
|
||||
func (s *Session) IncrementRewrite() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -93,26 +93,35 @@ func TryAcquireSessionLease(path string) (*SessionLease, error) {
|
||||
}
|
||||
|
||||
// TryReclaimCurrentProcessSessionLease re-acquires a lease whose in-process
|
||||
// owner entry was orphaned (a lease dropped without Release). It only succeeds
|
||||
// when the on-disk lease info identifies the current process AND the OS lease
|
||||
// lock is free: an active holder keeps its lock file locked, so reclaiming
|
||||
// from it fails with ErrSessionLeaseHeld without touching the holder's entry.
|
||||
// owner entry was orphaned (a lease dropped without Release). The OS lease
|
||||
// lock is the arbiter: an active holder keeps its lock file locked for the
|
||||
// whole hold, so reclaiming from one fails with ErrSessionLeaseHeld without
|
||||
// touching the holder's entry. Holding the lock proves nobody does, which
|
||||
// also covers metadata-damage states — a missing or unreadable lease info
|
||||
// (deleted by the user, quarantined by AV, torn by a crash) with a free lock
|
||||
// is a leftover, not a holder, and must not wedge the session as busy.
|
||||
func TryReclaimCurrentProcessSessionLease(path string) (*SessionLease, error) {
|
||||
path = canonicalSessionSavePath(path)
|
||||
info, err := LoadSessionLeaseInfo(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// The holder finished releasing between the caller's failed
|
||||
// acquire and now; a regular acquire can win the lease cleanly.
|
||||
return TryAcquireSessionLease(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() {
|
||||
// A readable info naming another live runtime: never steal it.
|
||||
// (A crashed foreign leftover is separated from a live holder by
|
||||
// the lock probe in SessionLeaseHeldByOtherRuntime; reclaim is
|
||||
// only for leases this process lost track of.)
|
||||
return nil, &SessionLeaseError{Path: path, Info: info}
|
||||
}
|
||||
// Unreadable lease info means the holder cannot be identified, so
|
||||
// reclaiming is unsafe. Report the lease as held rather than leaking
|
||||
// a raw filesystem error to callers that surface it to users.
|
||||
return nil, &SessionLeaseError{Path: path}
|
||||
}
|
||||
if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() {
|
||||
return nil, &SessionLeaseError{Path: path, Info: info}
|
||||
case os.IsNotExist(err):
|
||||
// The holder finished releasing (info removed first) or the sidecar
|
||||
// was deleted out from under an orphaned entry. Either way the lock
|
||||
// probe below decides; info identity has nothing left to say.
|
||||
info = nil
|
||||
default:
|
||||
// Unreadable info hides the holder's identity, but the lock still
|
||||
// tells the truth: a live holder keeps it locked. Fall through to the
|
||||
// probe instead of wedging on metadata damage.
|
||||
info = nil
|
||||
}
|
||||
unlock, err := tryLockSessionLeaseFile(path)
|
||||
if err != nil {
|
||||
@@ -203,6 +212,16 @@ func (l *SessionLease) Release() {
|
||||
// Only remove the entry this lease owns: after a reclaim the map may
|
||||
// already point at a newer lease for the same path.
|
||||
sessionLeaseOwners.CompareAndDelete(l.path, l.ownerID)
|
||||
// Best-effort retirement of the lock sidecars this session no longer
|
||||
// needs. Historically they were left behind on every release and only
|
||||
// swept on the next boot reconcile, so ordinary use accumulated
|
||||
// .lock/.lease.lock files (#6014). The helpers re-take each lock
|
||||
// non-blocking and delete it atomically with the release, so a new
|
||||
// holder or an in-flight save simply turns this into a no-op. This
|
||||
// must run after CompareAndDelete: the lease-lock helper skips paths
|
||||
// the owner registry still reports as held by this process.
|
||||
_ = removeStaleSessionLeaseLockSidecar(l.path, store.SessionLeaseLock(l.path))
|
||||
_ = removeStaleSessionLockSidecar(l.path, store.SessionLockFile(l.path))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,35 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// leaseTestPath returns a session path in "user shape" — mixed case, exactly
|
||||
// as desktop/CLI callers pass it — plus its canonical registry key for
|
||||
// internal-state setup and assertions. Tests must feed the user shape to the
|
||||
// API under test: feeding pre-canonicalized paths is how the Windows
|
||||
// case-fold mismatch (#5999) escaped this suite. On non-Windows hosts the two
|
||||
// forms are identical; on Windows they differ and exercise the fold.
|
||||
func leaseTestPath(t *testing.T) (userPath, key string) {
|
||||
t.Helper()
|
||||
userPath = filepath.Join(t.TempDir(), "Sessions-Dir", "Session-Test.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
return userPath, canonicalSessionSavePath(userPath)
|
||||
}
|
||||
|
||||
func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
first, err := TryAcquireSessionLease(path)
|
||||
userPath, _ := leaseTestPath(t)
|
||||
first, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
if first.Path() == "" {
|
||||
t.Fatal("first lease path is empty")
|
||||
}
|
||||
info, err := LoadSessionLeaseInfo(path)
|
||||
info, err := LoadSessionLeaseInfo(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessionLeaseInfo: %v", err)
|
||||
}
|
||||
@@ -26,7 +43,7 @@ func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
|
||||
t.Fatalf("lease info = %+v, want writer metadata", info)
|
||||
}
|
||||
|
||||
second, err := TryAcquireSessionLease(path)
|
||||
second, err := TryAcquireSessionLease(userPath)
|
||||
if !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
t.Fatalf("second TryAcquireSessionLease err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
@@ -36,7 +53,7 @@ func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
|
||||
}
|
||||
|
||||
first.Release()
|
||||
third, err := TryAcquireSessionLease(path)
|
||||
third, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("third TryAcquireSessionLease after release: %v", err)
|
||||
}
|
||||
@@ -44,43 +61,112 @@ func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionLeaseReclaimsCurrentProcessStaleOwner(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
path = canonicalSessionSavePath(path)
|
||||
sessionLeaseOwners.Store(path, struct{}{})
|
||||
userPath, key := leaseTestPath(t)
|
||||
sessionLeaseOwners.Store(key, struct{}{})
|
||||
t.Cleanup(func() {
|
||||
sessionLeaseOwners.Delete(path)
|
||||
_ = os.Remove(sessionLeaseInfoPath(path))
|
||||
sessionLeaseOwners.Delete(key)
|
||||
_ = os.Remove(sessionLeaseInfoPath(key))
|
||||
})
|
||||
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
|
||||
SessionPath: path,
|
||||
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
|
||||
SessionPath: key,
|
||||
WriterID: SessionWriterID(),
|
||||
PID: os.Getpid(),
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSessionLeaseInfo: %v", err)
|
||||
}
|
||||
if lease, err := TryAcquireSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
t.Fatalf("TryAcquireSessionLease err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
lease, err := TryReclaimCurrentProcessSessionLease(path)
|
||||
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryReclaimCurrentProcessSessionLease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
sessionLeaseOwners.Store(path, struct{}{})
|
||||
func TestSessionLeaseReclaimsOrphanedEntryWithoutInfo(t *testing.T) {
|
||||
// An orphaned in-process entry whose lease.json was deleted out from
|
||||
// under it (manual cleanup, AV quarantine). Nothing actually holds the
|
||||
// session — the OS lock is free — so reclaim must recover instead of
|
||||
// wedging every rebuild as busy. Before the lock-arbiter rework this
|
||||
// deadlocked: reclaim fell back to a plain acquire, which re-hit the
|
||||
// orphaned map entry forever.
|
||||
userPath, key := leaseTestPath(t)
|
||||
sessionLeaseOwners.Store(key, uint64(1<<61))
|
||||
t.Cleanup(func() { sessionLeaseOwners.Delete(key) })
|
||||
|
||||
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
t.Fatalf("TryAcquireSessionLease err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryReclaimCurrentProcessSessionLease without info: %v", err)
|
||||
}
|
||||
if _, err := LoadSessionLeaseInfo(userPath); err != nil {
|
||||
t.Fatalf("reclaim should have rewritten lease info, load err = %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
func TestSessionLeaseReclaimsOrphanedEntryWithCorruptInfo(t *testing.T) {
|
||||
// Same as above but the sidecar is torn (empty/undecodable) rather than
|
||||
// missing: identity is unreadable, the lock is free, reclaim must win.
|
||||
userPath, key := leaseTestPath(t)
|
||||
sessionLeaseOwners.Store(key, uint64(1<<61))
|
||||
t.Cleanup(func() {
|
||||
sessionLeaseOwners.Delete(path)
|
||||
_ = os.Remove(sessionLeaseInfoPath(path))
|
||||
sessionLeaseOwners.Delete(key)
|
||||
_ = os.Remove(sessionLeaseInfoPath(key))
|
||||
})
|
||||
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
|
||||
SessionPath: path,
|
||||
if err := os.WriteFile(sessionLeaseInfoPath(key), []byte("{torn"), 0o644); err != nil {
|
||||
t.Fatalf("write corrupt lease info: %v", err)
|
||||
}
|
||||
|
||||
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryReclaimCurrentProcessSessionLease with corrupt info: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
func TestSessionLeaseReclaimRefusesForeignInfo(t *testing.T) {
|
||||
// A readable info naming another runtime is never stolen by reclaim,
|
||||
// even with the lock free — that separation belongs to
|
||||
// SessionLeaseHeldByOtherRuntime's cleanup, not to reclaim.
|
||||
userPath, key := leaseTestPath(t)
|
||||
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
|
||||
SessionPath: key,
|
||||
WriterID: "other-host-1234-deadbeef",
|
||||
PID: os.Getpid() + 1,
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSessionLeaseInfo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(key)) })
|
||||
|
||||
if lease, err := TryReclaimCurrentProcessSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
t.Fatalf("TryReclaimCurrentProcessSessionLease err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
|
||||
userPath, key := leaseTestPath(t)
|
||||
sessionLeaseOwners.Store(key, struct{}{})
|
||||
t.Cleanup(func() {
|
||||
sessionLeaseOwners.Delete(key)
|
||||
_ = os.Remove(sessionLeaseInfoPath(key))
|
||||
})
|
||||
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
|
||||
SessionPath: key,
|
||||
WriterID: SessionWriterID(),
|
||||
PID: os.Getpid(),
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
@@ -97,7 +183,7 @@ func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if lease, err := TryReclaimCurrentProcessSessionLease(path); err == nil && lease != nil {
|
||||
if lease, err := TryReclaimCurrentProcessSessionLease(userPath); err == nil && lease != nil {
|
||||
leases <- lease
|
||||
}
|
||||
}()
|
||||
@@ -114,17 +200,17 @@ func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
|
||||
t.Fatalf("concurrent reclaim produced %d leases, want exactly 1", len(won))
|
||||
}
|
||||
// The losers must not have evicted the winner's owner entry.
|
||||
if _, ok := sessionLeaseOwners.Load(path); !ok {
|
||||
if _, ok := sessionLeaseOwners.Load(key); !ok {
|
||||
t.Fatal("winner's owner entry was evicted by a failed concurrent reclaim")
|
||||
}
|
||||
if lease, err := TryAcquireSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
t.Fatalf("TryAcquireSessionLease while reclaimed lease is held err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
won[0].Release()
|
||||
lease, err := TryAcquireSessionLease(path)
|
||||
lease, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease after release: %v", err)
|
||||
}
|
||||
@@ -132,24 +218,24 @@ func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionLeaseReclaimRefusesActiveHolder(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
holder, err := TryAcquireSessionLease(path)
|
||||
userPath, key := leaseTestPath(t)
|
||||
holder, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
defer holder.Release()
|
||||
|
||||
if lease, err := TryReclaimCurrentProcessSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease, err := TryReclaimCurrentProcessSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
t.Fatalf("TryReclaimCurrentProcessSessionLease err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
// The failed reclaim must leave the holder's owner entry intact.
|
||||
if _, ok := sessionLeaseOwners.Load(path); !ok {
|
||||
if _, ok := sessionLeaseOwners.Load(key); !ok {
|
||||
t.Fatal("active holder's owner entry was evicted by a failed reclaim")
|
||||
}
|
||||
if lease, err := TryAcquireSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if lease != nil {
|
||||
lease.Release()
|
||||
}
|
||||
@@ -158,16 +244,17 @@ func TestSessionLeaseReclaimRefusesActiveHolder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionLeaseReclaimAfterHolderReleased(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
holder, err := TryAcquireSessionLease(path)
|
||||
userPath, _ := leaseTestPath(t)
|
||||
holder, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
holder.Release()
|
||||
|
||||
// The holder released between the caller's failed acquire and the
|
||||
// reclaim: the lease info file is gone and a plain acquire must win.
|
||||
lease, err := TryReclaimCurrentProcessSessionLease(path)
|
||||
// reclaim: the lease info file is gone and the lock is free, so the
|
||||
// reclaim must win the lease cleanly.
|
||||
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryReclaimCurrentProcessSessionLease after release: %v", err)
|
||||
}
|
||||
@@ -175,96 +262,126 @@ func TestSessionLeaseReclaimAfterHolderReleased(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionLeaseStaleReleaseKeepsNewOwnerEntry(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
stale, err := TryAcquireSessionLease(path)
|
||||
userPath, key := leaseTestPath(t)
|
||||
stale, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
// Simulate a reclaim that took over the entry while the stale lease was
|
||||
// still alive: the map now names a different owner.
|
||||
sessionLeaseOwners.Store(path, uint64(1<<62))
|
||||
t.Cleanup(func() { sessionLeaseOwners.Delete(path) })
|
||||
sessionLeaseOwners.Store(key, uint64(1<<62))
|
||||
t.Cleanup(func() { sessionLeaseOwners.Delete(key) })
|
||||
|
||||
stale.Release()
|
||||
if _, ok := sessionLeaseOwners.Load(path); !ok {
|
||||
if _, ok := sessionLeaseOwners.Load(key); !ok {
|
||||
t.Fatal("stale Release evicted the new owner's entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLeaseHeldByOtherRuntime(t *testing.T) {
|
||||
t.Run("no lease", func(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
if SessionLeaseHeldByOtherRuntime(path) {
|
||||
userPath, _ := leaseTestPath(t)
|
||||
if SessionLeaseHeldByOtherRuntime(userPath) {
|
||||
t.Fatal("unheld session reported as held by another runtime")
|
||||
}
|
||||
})
|
||||
t.Run("held by this process", func(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
lease, err := TryAcquireSessionLease(path)
|
||||
userPath, _ := leaseTestPath(t)
|
||||
lease, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
if SessionLeaseHeldByOtherRuntime(path) {
|
||||
if SessionLeaseHeldByOtherRuntime(userPath) {
|
||||
t.Fatal("own lease reported as held by another runtime")
|
||||
}
|
||||
})
|
||||
t.Run("foreign info with live lock", func(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
unlock, err := tryLockSessionLeaseFile(path)
|
||||
userPath, key := leaseTestPath(t)
|
||||
unlock, err := tryLockSessionLeaseFile(key)
|
||||
if err != nil {
|
||||
t.Fatalf("tryLockSessionLeaseFile: %v", err)
|
||||
}
|
||||
defer unlock()
|
||||
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
|
||||
SessionPath: path,
|
||||
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
|
||||
SessionPath: key,
|
||||
WriterID: "other-host-1234-deadbeef",
|
||||
PID: os.Getpid() + 1,
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSessionLeaseInfo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(path)) })
|
||||
if !SessionLeaseHeldByOtherRuntime(path) {
|
||||
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(key)) })
|
||||
if !SessionLeaseHeldByOtherRuntime(userPath) {
|
||||
t.Fatal("foreign-held session not reported as held by another runtime")
|
||||
}
|
||||
})
|
||||
t.Run("foreign info from crashed process", func(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
|
||||
SessionPath: path,
|
||||
userPath, key := leaseTestPath(t)
|
||||
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
|
||||
SessionPath: key,
|
||||
WriterID: "other-host-1234-deadbeef",
|
||||
PID: os.Getpid() + 1,
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSessionLeaseInfo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(path)) })
|
||||
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(key)) })
|
||||
// Info file left behind but the lock is free: the holder crashed, so
|
||||
// the session is not considered held.
|
||||
if SessionLeaseHeldByOtherRuntime(path) {
|
||||
if SessionLeaseHeldByOtherRuntime(userPath) {
|
||||
t.Fatal("crashed holder's leftover info reported as held")
|
||||
}
|
||||
if _, err := os.Stat(sessionLeaseInfoPath(path)); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(sessionLeaseInfoPath(key)); !os.IsNotExist(err) {
|
||||
t.Fatalf("crashed holder's leftover info should be removed, stat err = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("corrupt info from crashed process", func(t *testing.T) {
|
||||
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(sessionLeaseInfoPath(path), nil, 0o644); err != nil {
|
||||
userPath, key := leaseTestPath(t)
|
||||
if err := os.WriteFile(sessionLeaseInfoPath(key), nil, 0o644); err != nil {
|
||||
t.Fatalf("write corrupt lease info: %v", err)
|
||||
}
|
||||
if SessionLeaseHeldByOtherRuntime(path) {
|
||||
if SessionLeaseHeldByOtherRuntime(userPath) {
|
||||
t.Fatal("corrupt crashed holder info reported as held")
|
||||
}
|
||||
if _, err := os.Stat(sessionLeaseInfoPath(path)); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(sessionLeaseInfoPath(key)); !os.IsNotExist(err) {
|
||||
t.Fatalf("corrupt lease info should be removed, stat err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionLeaseReleaseRetiresLockSidecars(t *testing.T) {
|
||||
userPath, key := leaseTestPath(t)
|
||||
lease, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
leaseLock := store.SessionLeaseLock(key)
|
||||
if _, err := os.Stat(leaseLock); err != nil {
|
||||
t.Fatalf("lease lock should exist while held: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
if _, err := os.Stat(leaseLock); !os.IsNotExist(err) {
|
||||
t.Fatalf("lease lock should be retired on release, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionLockFile(key)); !os.IsNotExist(err) {
|
||||
t.Fatalf("save lock should be retired on release, stat err = %v", err)
|
||||
}
|
||||
|
||||
// A release racing a live successor must not strip the successor's lock.
|
||||
first, err := TryAcquireSessionLease(userPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reacquire: %v", err)
|
||||
}
|
||||
second, err := TryAcquireSessionLease(userPath)
|
||||
if !errors.Is(err, ErrSessionLeaseHeld) {
|
||||
if second != nil {
|
||||
second.Release()
|
||||
}
|
||||
t.Fatalf("second acquire err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if _, err := os.Stat(leaseLock); err != nil {
|
||||
t.Fatalf("holder's lease lock must survive a failed acquire: %v", err)
|
||||
}
|
||||
first.Release()
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// shrinkMetaReadBackoffs keeps corrupt-sidecar tests fast. Only the pacing of
|
||||
// the torn-read retries changes; the retry-then-fail semantics stay intact.
|
||||
func shrinkMetaReadBackoffs(t *testing.T) {
|
||||
t.Helper()
|
||||
old := branchMetaReadBackoffs
|
||||
branchMetaReadBackoffs = []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond}
|
||||
t.Cleanup(func() { branchMetaReadBackoffs = old })
|
||||
}
|
||||
|
||||
func assertNoRecoveryBranches(t *testing.T, sessionPath string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(filepath.Dir(sessionPath), "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("unexpected recovery branches: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
// A meta sidecar that exists but cannot be parsed must fail the save instead
|
||||
// of being silently treated as revision 0 — the desync that used to fork
|
||||
// bogus recovery branches. The unreadable ledger must also survive untouched
|
||||
// so a healed read can pick up the real revision.
|
||||
func TestSaveFailsClosedOnCorruptMetaLedger(t *testing.T) {
|
||||
shrinkMetaReadBackoffs(t)
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
metaPath := BranchMetaPath(path)
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
good, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read good meta: %v", err)
|
||||
}
|
||||
corrupt := []byte("{ torn json")
|
||||
if err := os.WriteFile(metaPath, corrupt, 0o644); err != nil {
|
||||
t.Fatalf("corrupt meta: %v", err)
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
saveErr := s.SaveSnapshot(path)
|
||||
if saveErr == nil {
|
||||
t.Fatal("SaveSnapshot with corrupt meta ledger succeeded, want error")
|
||||
}
|
||||
if errors.Is(saveErr, ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("SaveSnapshot with corrupt meta misread as conflict: %v", saveErr)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
if b, err := os.ReadFile(metaPath); err != nil || string(b) != string(corrupt) {
|
||||
t.Fatalf("failed save rewrote the unreadable ledger: err=%v content=%q", err, b)
|
||||
}
|
||||
onDisk, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession after failed save: %v", err)
|
||||
}
|
||||
if got := len(onDisk.Messages); got != 2 {
|
||||
t.Fatalf("failed save changed transcript: %d messages, want 2", got)
|
||||
}
|
||||
|
||||
// Once the sidecar reads cleanly again the same session saves normally.
|
||||
if err := os.WriteFile(metaPath, good, 0o644); err != nil {
|
||||
t.Fatalf("restore meta: %v", err)
|
||||
}
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot after meta repair: %v", err)
|
||||
}
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after repair ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 2 {
|
||||
t.Fatalf("revision after repaired save = %d, want 2", meta.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable meta sidecar at load time must not stop the session from
|
||||
// opening, and the revision-0 placeholder must not arm the CAS check: once
|
||||
// the sidecar reads cleanly again, appends save without a bogus conflict.
|
||||
func TestLoadSessionWithUnreadableMetaStillOpensAndAppends(t *testing.T) {
|
||||
shrinkMetaReadBackoffs(t)
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
metaPath := BranchMetaPath(path)
|
||||
seed := NewSession("sys")
|
||||
seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := seed.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot seed: %v", err)
|
||||
}
|
||||
good, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read good meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
|
||||
t.Fatalf("corrupt meta: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession with unreadable meta: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 3 {
|
||||
t.Fatalf("loaded %d messages, want 3", got)
|
||||
}
|
||||
if !loaded.persisted.ok {
|
||||
t.Fatal("load must still establish a persistence baseline")
|
||||
}
|
||||
if loaded.persisted.revisionKnown {
|
||||
t.Fatal("baseline built without a readable ledger must be revision-unknown")
|
||||
}
|
||||
|
||||
// The tear heals (another runtime's write completes / the corruption was
|
||||
// transient): the on-disk revision is an honest 1 against this runtime's
|
||||
// unknown baseline. The append must not be misread as a stale runtime.
|
||||
if err := os.WriteFile(metaPath, good, 0o644); err != nil {
|
||||
t.Fatalf("restore meta: %v", err)
|
||||
}
|
||||
loaded.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot append after unreadable-meta load: %v", err)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after append ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 2 {
|
||||
t.Fatalf("revision after append = %d, want 2", meta.Revision)
|
||||
}
|
||||
if !loaded.persisted.revisionKnown {
|
||||
t.Fatal("successful save must re-learn the revision baseline")
|
||||
}
|
||||
|
||||
// With the baseline re-learned, revision CAS is armed again.
|
||||
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot second append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction-style rewrites from a revision-unknown baseline must fall back to
|
||||
// digest+version ownership instead of failing the revision equality check.
|
||||
func TestSaveRewriteWithUnreadableMetaBaselineOwnsByDigest(t *testing.T) {
|
||||
shrinkMetaReadBackoffs(t)
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
metaPath := BranchMetaPath(path)
|
||||
seed := NewSession("sys")
|
||||
seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "a long detailed answer"})
|
||||
if err := seed.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot seed: %v", err)
|
||||
}
|
||||
good, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read good meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
|
||||
t.Fatalf("corrupt meta: %v", err)
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession with unreadable meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, good, 0o644); err != nil {
|
||||
t.Fatalf("restore meta: %v", err)
|
||||
}
|
||||
|
||||
rewritten := loaded.Snapshot()
|
||||
rewritten[len(rewritten)-1].Content = "compacted"
|
||||
loaded.Replace(rewritten)
|
||||
if err := loaded.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite from unknown-revision baseline: %v", err)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after rewrite ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 2 {
|
||||
t.Fatalf("revision after rewrite = %d, want 2", meta.Revision)
|
||||
}
|
||||
reloaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession after rewrite: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "compacted" {
|
||||
t.Fatalf("rewrite tail = %q, want compacted", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing sidecar is not damage: it is the legitimate revision-0 state of a
|
||||
// session that never recorded one, and must keep arming the CAS baseline.
|
||||
func TestMissingMetaRemainsKnownZeroRevisionBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
if err := os.Remove(BranchMetaPath(path)); err != nil {
|
||||
t.Fatalf("remove meta: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession without meta: %v", err)
|
||||
}
|
||||
if !loaded.persisted.ok || !loaded.persisted.revisionKnown || loaded.persisted.revision != 0 {
|
||||
t.Fatalf("missing meta baseline = %+v, want known revision 0", loaded.persisted)
|
||||
}
|
||||
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot append without meta: %v", err)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recreated ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 1 {
|
||||
t.Fatalf("recreated revision = %d, want 1", meta.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
// Losing the event index (a listing accelerator, never read by LoadSession)
|
||||
// must not fail a save whose transcript and revision already landed, and must
|
||||
// not leave the baseline behind disk where the next save reads as a conflict.
|
||||
func TestSaveSnapshotToleratesEventIndexWriteFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
// Squat a directory on the index path so every index write must fail.
|
||||
if err := os.MkdirAll(SessionEventIndexPath(path), 0o755); err != nil {
|
||||
t.Fatalf("pre-create index dir: %v", err)
|
||||
}
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot with failing event index = %v, want nil", err)
|
||||
}
|
||||
first, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta first ok=%v err=%v", ok, err)
|
||||
}
|
||||
if first.Revision != 1 {
|
||||
t.Fatalf("first revision = %d, want 1", first.Revision)
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("second SaveSnapshot = %v, want nil (baseline must advance despite index failure)", err)
|
||||
}
|
||||
second, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta second ok=%v err=%v", ok, err)
|
||||
}
|
||||
if second.Revision != 2 {
|
||||
t.Fatalf("second revision = %d, want 2", second.Revision)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 3 {
|
||||
t.Fatalf("loaded %d messages, want 3", got)
|
||||
}
|
||||
if info, err := os.Stat(SessionEventIndexPath(path)); err != nil || !info.IsDir() {
|
||||
t.Fatalf("fixture broke: index path no longer a directory (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Meta-only writers (rename, model stamp) racing content saves must never
|
||||
// roll the revision ledger backwards or manufacture conflicts.
|
||||
func TestSessionMetaConcurrentWritersKeepRevisionMonotonic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot seed: %v", err)
|
||||
}
|
||||
if err := RenameSession(path, "seed-title"); err != nil {
|
||||
t.Fatalf("RenameSession seed: %v", err)
|
||||
}
|
||||
if err := SetBranchModelPreserveUpdated(path, "prov/model-seed"); err != nil {
|
||||
t.Fatalf("SetBranchModelPreserveUpdated seed: %v", err)
|
||||
}
|
||||
|
||||
const saves = 25
|
||||
stop := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
stopAll := func() { stopOnce.Do(func() { close(stop) }) }
|
||||
var wg sync.WaitGroup
|
||||
defer func() {
|
||||
stopAll()
|
||||
wg.Wait()
|
||||
}()
|
||||
metaErrCh := make(chan error, 2)
|
||||
var regressed atomic.Bool
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := RenameSession(path, fmt.Sprintf("title-%d", i)); err != nil {
|
||||
select {
|
||||
case metaErrCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := SetBranchModelPreserveUpdated(path, fmt.Sprintf("prov/model-%d", i)); err != nil {
|
||||
select {
|
||||
case metaErrCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
last := int64(0)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if meta, ok, err := LoadBranchMeta(path); err == nil && ok {
|
||||
if meta.Revision < last {
|
||||
regressed.Store(true)
|
||||
return
|
||||
}
|
||||
last = meta.Revision
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 1; i <= saves; i++ {
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot %d under meta-writer hammer: %v", i, err)
|
||||
}
|
||||
}
|
||||
stopAll()
|
||||
wg.Wait()
|
||||
close(metaErrCh)
|
||||
for err := range metaErrCh {
|
||||
t.Fatalf("meta writer error: %v", err)
|
||||
}
|
||||
if regressed.Load() {
|
||||
t.Fatal("meta revision regressed while meta-only writers raced saves")
|
||||
}
|
||||
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
|
||||
}
|
||||
if want := int64(saves + 1); meta.Revision != want {
|
||||
t.Fatalf("final revision = %d, want %d", meta.Revision, want)
|
||||
}
|
||||
digest, err := digestSessionMessages(s.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("digest final content: %v", err)
|
||||
}
|
||||
if meta.ContentDigest != digestString(digest) {
|
||||
t.Fatalf("final content digest = %q, want %q", meta.ContentDigest, digestString(digest))
|
||||
}
|
||||
if meta.CustomTitle == "" || meta.Model == "" {
|
||||
t.Fatalf("meta-only fields lost under hammer: %+v", meta)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
}
|
||||
|
||||
// A saver that blocks on the save lock must persist whatever the session
|
||||
// holds when it finally enters the critical section, not a stale capture from
|
||||
// when it was scheduled — the out-of-order landing that used to surface as
|
||||
// "session changed on disk" adoptions between the turn-end snapshot, periodic
|
||||
// autosave, and shutdown snapshot.
|
||||
func TestSaveSnapshotCapturesContentUnderSaveLock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot seed: %v", err)
|
||||
}
|
||||
|
||||
unlock := lockSessionSavePath(path)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.SaveSnapshot(path) }()
|
||||
// Let the saver reach the save lock, then grow the session while it waits.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "added while saver waited"})
|
||||
unlock()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("SaveSnapshot after lock release: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got := len(loaded.Messages); got != 3 {
|
||||
t.Fatalf("saved %d messages, want 3 (snapshot must be captured under the save lock)", got)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
}
|
||||
|
||||
// Multiple in-process savers of one session (turn-end snapshot, periodic
|
||||
// autosave, shutdown snapshot) must never conflict with each other: the
|
||||
// snapshot is captured under the save lock, so a stale pre-lock capture can
|
||||
// no longer land after a newer one.
|
||||
func TestConcurrentSnapshotSaversNeverConflict(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot seed: %v", err)
|
||||
}
|
||||
|
||||
const adds = 30
|
||||
stop := make(chan struct{})
|
||||
errCh := make(chan error, 64)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 1; i <= adds; i++ {
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
if errors.Is(err, ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("concurrent snapshot savers conflicted: %v", err)
|
||||
}
|
||||
t.Errorf("concurrent snapshot saver error: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("final SaveSnapshot: %v", err)
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got, want := len(loaded.Messages), adds+2; got != want {
|
||||
t.Fatalf("final message count = %d, want %d", got, want)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
}
|
||||
|
||||
// A save that lands its transcript bytes and then fails to record the
|
||||
// revision (fail-closed record, or a crash between the two writes) leaves the
|
||||
// ledger describing older content. The next save of the same snapshot takes
|
||||
// the up-to-date path and must heal the ledger — record the revision and
|
||||
// digest the interrupted save deferred — instead of skipping it forever.
|
||||
func TestSameContentSaveHealsStaleLedgerDigest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
metaPath := BranchMetaPath(path)
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
staleMeta, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read base meta: %v", err)
|
||||
}
|
||||
|
||||
// Land new content, then rewind the sidecar to the pre-append ledger:
|
||||
// the exact on-disk aftermath of an append whose revision record failed.
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot append: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
|
||||
t.Fatalf("rewind meta: %v", err)
|
||||
}
|
||||
|
||||
// Any runtime resuming this file now pairs the landed transcript with the
|
||||
// stale revision — the post-crash shape.
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession on stale ledger: %v", err)
|
||||
}
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("same-content SaveSnapshot: %v", err)
|
||||
}
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 2 {
|
||||
t.Fatalf("healed revision = %d, want 2", meta.Revision)
|
||||
}
|
||||
digest, err := digestSessionMessages(loaded.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("digest current messages: %v", err)
|
||||
}
|
||||
if meta.ContentDigest != digestString(digest) {
|
||||
t.Fatalf("healed digest = %s, want %s", meta.ContentDigest, digestString(digest))
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
|
||||
// The healed baseline keeps working: the next append saves cleanly.
|
||||
loaded.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("append after heal: %v", err)
|
||||
}
|
||||
after, _, err := LoadBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBranchMeta after append: %v", err)
|
||||
}
|
||||
if after.Revision != 3 {
|
||||
t.Fatalf("revision after post-heal append = %d, want 3", after.Revision)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
}
|
||||
|
||||
// The surviving in-process saver — whose baseline never advanced because the
|
||||
// failed save returned before markPersisted — heals through the same
|
||||
// up-to-date path on its autosave retry of the identical snapshot.
|
||||
func TestSameContentRetryHealsLedgerForSurvivingSaver(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
metaPath := BranchMetaPath(path)
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
staleMeta, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read base meta: %v", err)
|
||||
}
|
||||
baseline := s.persistState(path)
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot append: %v", err)
|
||||
}
|
||||
// Rewind the sidecar and the in-memory baseline to the mid-save failure
|
||||
// state: bytes landed, record failed, markPersisted never ran.
|
||||
if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
|
||||
t.Fatalf("rewind meta: %v", err)
|
||||
}
|
||||
s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true, 0)
|
||||
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("autosave retry: %v", err)
|
||||
}
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 2 {
|
||||
t.Fatalf("healed revision = %d, want 2", meta.Revision)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
}
|
||||
|
||||
// A legacy sidecar that predates content digests is not a stale ledger: the
|
||||
// up-to-date path must leave it untouched rather than bump a revision other
|
||||
// runtimes still hold as their baseline.
|
||||
func TestUpToDateSaveLeavesDigestlessLegacyMetaAlone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
metaPath := BranchMetaPath(path)
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot base: %v", err)
|
||||
}
|
||||
// Strip the digest by editing the raw sidecar: the save helpers back-fill
|
||||
// an empty digest from the existing meta, exactly like real legacy files
|
||||
// acquired one only through a content-bearing save.
|
||||
raw, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read meta: %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
t.Fatalf("decode meta: %v", err)
|
||||
}
|
||||
delete(fields, "content_digest")
|
||||
stripped, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatalf("encode meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, stripped, 0o644); err != nil {
|
||||
t.Fatalf("write legacy meta: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession legacy meta: %v", err)
|
||||
}
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("same-content SaveSnapshot: %v", err)
|
||||
}
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Revision != 1 {
|
||||
t.Fatalf("legacy revision = %d, want 1 (no gratuitous bump)", meta.Revision)
|
||||
}
|
||||
if meta.ContentDigest != "" {
|
||||
t.Fatalf("legacy digest = %q, want empty (untouched)", meta.ContentDigest)
|
||||
}
|
||||
assertNoRecoveryBranches(t, path)
|
||||
}
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user