mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d68fb1a93 | ||
|
|
5ea5593f40 | ||
|
|
bdeeb644c4 | ||
|
|
6c9671aa57 | ||
|
|
030a4d5363 | ||
|
|
9087238c7a | ||
|
|
704b15f4d2 | ||
|
|
38ec4093fe | ||
|
|
079293f0d1 | ||
|
|
4c5e5a9572 | ||
|
|
a22eba4a86 | ||
|
|
99c1539142 | ||
|
|
4524016932 | ||
|
|
10e637bce6 | ||
|
|
0af75a1939 | ||
|
|
344895e496 | ||
|
|
0c2efcd5ae | ||
|
|
3cb37b5c9c | ||
|
|
eb9e3e8e0f | ||
|
|
d0cc98d9d3 | ||
|
|
c3ef4bfc92 | ||
|
|
7cca6e9770 | ||
|
|
431dddb1ef | ||
|
|
8178abc0bf | ||
|
|
009a2359cb | ||
|
|
972f052277 | ||
|
|
f501f3dba0 | ||
|
|
80cf5274bf | ||
|
|
4317a6176b | ||
|
|
3716462392 | ||
|
|
2f4a6995f1 | ||
|
|
1401a07a49 | ||
|
|
d39a7694d3 | ||
|
|
fb1840f22d | ||
|
|
8652137e92 | ||
|
|
75c858e543 | ||
|
|
9c058ef04f | ||
|
|
a665c88192 | ||
|
|
fa646b75c9 | ||
|
|
2d489f5c5f | ||
|
|
4098c4d98f | ||
|
|
07c65c2222 | ||
|
|
8357312237 | ||
|
|
0403d1d1a4 | ||
|
|
824f232478 | ||
|
|
14801d3118 | ||
|
|
64253776a8 | ||
|
|
2a3543adca | ||
|
|
cd9023ecf8 | ||
|
|
78133b1a26 | ||
|
|
8b0492525a | ||
|
|
43b147e4f2 | ||
|
|
30ecaf4354 | ||
|
|
9dce3d05fc | ||
|
|
8be5af9f6f | ||
|
|
c9ea166b04 | ||
|
|
5992b2ef25 | ||
|
|
bc556ce363 | ||
|
|
b98d4277c5 | ||
|
|
c009c1bfdf | ||
|
|
7dd84c5d25 | ||
|
|
7ac91a41ec | ||
|
|
53bd6bf901 | ||
|
|
d743a99ae8 | ||
|
|
3cfc430341 | ||
|
|
b07876d2c2 | ||
|
|
4b764dae33 | ||
|
|
741f8e58d5 | ||
|
|
54f11756af | ||
|
|
c0d0235274 | ||
|
|
eb8f4b82ff | ||
|
|
7c437b2d9a | ||
|
|
17074c1ede | ||
|
|
089a3144fb | ||
|
|
b2dae608ad | ||
|
|
65cded04a9 | ||
|
|
2fbce2834a | ||
|
|
9535e411e3 | ||
|
|
7eeb5c2125 | ||
|
|
725e10e560 | ||
|
|
c0100dd509 | ||
|
|
6a68799e18 | ||
|
|
60735b4f02 | ||
|
|
c070dc7432 | ||
|
|
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
@@ -1,2 +1,28 @@
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.npmrc text eol=lf
|
||||
*.astro text eol=lf
|
||||
*.css text eol=lf
|
||||
*.go text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.nsi text eol=lf
|
||||
*.plist text eol=lf
|
||||
*.py text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.sum text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
.githooks/** text eol=lf
|
||||
benchmarks/**/*.sh text eol=lf
|
||||
@@ -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:
|
||||
@@ -149,6 +163,53 @@ jobs:
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
# Desktop project-root matching is case-insensitive only on Windows
|
||||
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
|
||||
# regression tests for that contract are named *OnWindows and can never
|
||||
# run on the ubuntu leg above.
|
||||
desktop-windows:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
# go:embed of frontend/dist needs a built frontend before the package
|
||||
# compiles, same as the ubuntu desktop leg.
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: test (Windows-only path semantics)
|
||||
timeout-minutes: 10
|
||||
run: go test . -run 'OnWindows$' -v
|
||||
|
||||
lint:
|
||||
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
|
||||
|
||||
+221
-29
@@ -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,27 +1750,49 @@ 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()
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2144,7 +2273,7 @@ func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool,
|
||||
WorkspaceRoot: s.WorkspaceRoot,
|
||||
TopicID: s.TopicID,
|
||||
TopicTitle: s.TopicTitle,
|
||||
Recovered: s.Recovered,
|
||||
Recovered: sessionInfoIsAutomaticRecovery(s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2247,6 +2376,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 {
|
||||
@@ -2641,7 +2774,7 @@ func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
|
||||
return fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
actualRoot = workspaceRoot
|
||||
} else {
|
||||
actualRoot = globalWorkspaceRoot()
|
||||
@@ -2728,6 +2861,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 +2923,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 +3156,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 +3198,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()
|
||||
@@ -3675,7 +3816,7 @@ func (a *App) ListWorkspaces() []WorkspaceMeta {
|
||||
out = append(out, WorkspaceMeta{
|
||||
Path: project.Root,
|
||||
Name: projectDisplayName(project),
|
||||
Current: activeRoot != "" && project.Root == activeRoot,
|
||||
Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -3921,6 +4062,7 @@ func (a *App) singleSurfaceLayoutEnabled() bool {
|
||||
type HistoryMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
SubmitText string `json:"submitText,omitempty"`
|
||||
CheckpointTurn *int `json:"checkpointTurn,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
@@ -4695,6 +4837,7 @@ type previewEventRecord struct {
|
||||
UpdatedAt json.RawMessage `json:"updatedAt"`
|
||||
UpdatedAtSnake json.RawMessage `json:"updated_at"`
|
||||
Text string `json:"text"`
|
||||
Detail string `json:"detail"`
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningContent string `json:"reasoningContent"`
|
||||
@@ -4802,7 +4945,7 @@ func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
|
||||
if level != "warn" {
|
||||
level = "info"
|
||||
}
|
||||
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})
|
||||
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail})
|
||||
case "compaction_started":
|
||||
c := rec.compactionPayload()
|
||||
out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
|
||||
@@ -4884,6 +5027,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 +7101,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 +7129,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 +7169,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 +7287,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 +7337,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 {
|
||||
@@ -7163,6 +7348,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
// The runtime now reflects the on-disk config; drop any deferred refresh.
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7261,6 +7447,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 +7489,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 {
|
||||
@@ -7312,6 +7500,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7386,6 +7575,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 +7617,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 {
|
||||
@@ -7437,6 +7628,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return 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-run-strip.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__/recovery-quiet-notifications.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__/sound.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__/history-recovery-copies.test.tsx && 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-run-strip.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__/recovery-quiet-notifications.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__/sound.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__/history-recovery-copies.test.tsx && 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",
|
||||
|
||||
+134
-55
@@ -38,14 +38,14 @@ import { useToast } from "./lib/toast";
|
||||
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
|
||||
import { localizedBackendNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
import { Composer } from "./components/Composer";
|
||||
import { TodoPanel } from "./components/TodoPanel";
|
||||
import { ApprovalModal } from "./components/ApprovalModal";
|
||||
import { ApprovalModal, approvalToolLabel } from "./components/ApprovalModal";
|
||||
import { AskCard } from "./components/AskCard";
|
||||
import { UndoRewindBanner } from "./components/UndoRewindBanner";
|
||||
import { ClearContextCard } from "./components/ClearContextCard";
|
||||
@@ -161,6 +161,83 @@ import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
import logoWordmark from "./assets/logo-wordmark.svg";
|
||||
|
||||
function noticePreviewMockEnabled(): boolean {
|
||||
const value = browserMockScenarioParam();
|
||||
return value === "notice" || value === "notices" || value === "notice-preview";
|
||||
}
|
||||
|
||||
function noticePreviewItems(): Item[] {
|
||||
const notice = (index: number, level: "info" | "warn", text: string, detail: string): Item => ({
|
||||
kind: "notice",
|
||||
id: `notice-preview-${index}`,
|
||||
level,
|
||||
text: localizedBackendNoticeText(text),
|
||||
detail,
|
||||
});
|
||||
return [
|
||||
notice(0, "info", "Task status needs one more check; asking the assistant to finish or explain what is blocking it.", "final-answer readiness blocked: latest successful todo_write still has incomplete items: UI spinner in_progress, settings persistence pending"),
|
||||
notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying"),
|
||||
notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute"),
|
||||
notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing"),
|
||||
notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts"),
|
||||
notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"),
|
||||
notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"),
|
||||
notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"),
|
||||
notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"),
|
||||
notice(9, "info", "Planning mode enabled for this multi-step task.", "auto plan detector matched implementation task with repository edits and verification steps"),
|
||||
notice(10, "info", "Plan detection requested a plan.", "plan detector confidence=0.83; task includes multiple dependent UI/backend changes"),
|
||||
notice(11, "info", "Plan detection was uncertain; using the fallback planner heuristic.", "plan detector confidence=0.48; fallback heuristic selected because files and tests are involved"),
|
||||
notice(12, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"),
|
||||
notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"),
|
||||
notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"),
|
||||
notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"),
|
||||
notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"),
|
||||
notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"),
|
||||
notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"),
|
||||
notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""),
|
||||
notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""),
|
||||
notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""),
|
||||
notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"),
|
||||
notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"),
|
||||
notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"),
|
||||
notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"),
|
||||
notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"),
|
||||
];
|
||||
}
|
||||
|
||||
function NoticePreviewPanel({ detailsLabel }: { detailsLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
padding: "44px 24px 128px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 920, margin: "0 auto" }}>
|
||||
{noticePreviewItems().map((item) => {
|
||||
if (item.kind !== "notice") return null;
|
||||
return (
|
||||
<div key={item.id} className={`notice-line notice-line--${item.level}`} data-entrance="true">
|
||||
<span className="notice-line__icon">{item.level === "warn" ? "⚠ " : "ℹ "}</span>
|
||||
<div className="notice-line__text">
|
||||
{item.text}
|
||||
{item.detail ? (
|
||||
<details className="notice-line__details">
|
||||
<summary>{detailsLabel}</summary>
|
||||
<div>{item.detail}</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
|
||||
|
||||
@@ -854,6 +931,9 @@ function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream)
|
||||
break;
|
||||
case "notice":
|
||||
lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
|
||||
if (item.detail?.trim()) {
|
||||
lines.push("Details:", "", item.detail.trim(), "");
|
||||
}
|
||||
break;
|
||||
case "compaction":
|
||||
lines.push("### Context Compaction", "");
|
||||
@@ -1010,6 +1090,7 @@ export default function App() {
|
||||
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
|
||||
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
|
||||
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
|
||||
const attentionChimeEvents = useRef(new Set<string>());
|
||||
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
|
||||
// workspace changes, git history, and session metadata after AI tool writes.
|
||||
useEffect(() => {
|
||||
@@ -1017,11 +1098,31 @@ export default function App() {
|
||||
if (e.kind === "turn_done") {
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
}
|
||||
if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) {
|
||||
playAttentionChime();
|
||||
}
|
||||
if (e.kind === "turn_done") {
|
||||
if (!e.err) playSuccessChime();
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
// Runtime rebuilds (model/effort/settings switch) replace the controller,
|
||||
// whose approval/ask ids restart from "1" — stale dedupe keys would mute
|
||||
// the first prompt after a rebuild. agent:ready fires when a (re)build
|
||||
// completes; clear that tab's keys (or all, for tab-less ready events).
|
||||
const unsubReady = onReady((readyTabId) => {
|
||||
clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
|
||||
});
|
||||
// Model/effort/token-mode switches and clear-while-running replace the
|
||||
// controller WITHOUT an agent:ready — they signal runtime:rebuilt instead
|
||||
// (a ready here would trigger a full session reload the UI already did).
|
||||
const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => {
|
||||
clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
unsubReady();
|
||||
unsubRebuilt();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
|
||||
@@ -2266,7 +2367,6 @@ export default function App() {
|
||||
if (!rewindState) return transcriptItems;
|
||||
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
|
||||
}, [transcriptItems, rewindState]);
|
||||
|
||||
const latestGuidanceConsumed = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const item = state.items[i];
|
||||
@@ -2279,8 +2379,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 +2396,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 +2406,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 +2648,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);
|
||||
@@ -2593,36 +2693,10 @@ export default function App() {
|
||||
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
|
||||
[enqueueNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecovered((event) => {
|
||||
const scope = event.scope === "project" ? "project" : "global";
|
||||
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
|
||||
? {
|
||||
actionLabel: t("recovery.open"),
|
||||
durationMs: 9000,
|
||||
onAction: () => {
|
||||
void enqueueNavigation({
|
||||
kind: "topic",
|
||||
scope,
|
||||
workspaceRoot,
|
||||
topicId: event.topicId || "",
|
||||
sessionPath: event.recoveryPath || "",
|
||||
});
|
||||
},
|
||||
}
|
||||
: { durationMs: 9000 });
|
||||
});
|
||||
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecoveryFailed((event) => {
|
||||
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
|
||||
showToast(t(key), "error", { durationMs: 9000 });
|
||||
});
|
||||
}, [showToast, t]);
|
||||
useEffect(() => onSessionRecovered(() => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
}), [refreshTabMetas]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
@@ -2747,6 +2821,19 @@ export default function App() {
|
||||
},
|
||||
[state.running, deleteSession, refreshHistoryView],
|
||||
);
|
||||
const onDeleteManySessions = useCallback(
|
||||
async (paths: string[]) => {
|
||||
if (state.running) return;
|
||||
const uniquePaths = Array.from(new Set(paths));
|
||||
for (const path of uniquePaths) {
|
||||
// Best effort per path: one locked/missing file must not abandon the
|
||||
// rest of the sweep. The final refresh reconciles whatever happened.
|
||||
await deleteSession(path).catch(() => undefined);
|
||||
}
|
||||
await refreshHistoryView();
|
||||
},
|
||||
[state.running, deleteSession, refreshHistoryView],
|
||||
);
|
||||
const onRenameSession = useCallback(
|
||||
async (path: string, title: string) => {
|
||||
if (state.running) return;
|
||||
@@ -2884,13 +2971,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>) => {
|
||||
@@ -3442,13 +3522,6 @@ export default function App() {
|
||||
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
|
||||
)}
|
||||
|
||||
{activeTab?.recovered && !sidebarImDetailConnection && (
|
||||
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
|
||||
|
||||
<main className="main">
|
||||
@@ -3460,6 +3533,8 @@ export default function App() {
|
||||
onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)}
|
||||
onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)}
|
||||
/>
|
||||
) : noticePreviewMockEnabled() ? (
|
||||
<NoticePreviewPanel detailsLabel={t("notice.details")} />
|
||||
) : (
|
||||
<Transcript
|
||||
items={displayItems}
|
||||
@@ -3535,6 +3610,7 @@ export default function App() {
|
||||
onStop={() => {
|
||||
cancel();
|
||||
}}
|
||||
toolApprovalMode={toolApprovalMode}
|
||||
/>
|
||||
)}
|
||||
{state.ask && (
|
||||
@@ -3586,6 +3662,8 @@ export default function App() {
|
||||
turnStartAt={state.turnStartAt}
|
||||
turnTokens={state.turnTokens}
|
||||
retry={state.retry}
|
||||
pendingApprovalLabel={state.approval ? approvalToolLabel(state.approval.tool, t) : null}
|
||||
pendingAsk={state.ask != null}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
@@ -3731,6 +3809,7 @@ export default function App() {
|
||||
onRestore={onRestoreTrashedSession}
|
||||
onPurge={onPurgeTrashedSession}
|
||||
onPurgeAll={onPurgeAllTrashedSessions}
|
||||
onDeleteMany={onDeleteManySessions}
|
||||
onClose={closeHistory}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -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({
|
||||
@@ -231,6 +312,25 @@ console.log("\napproval modal file references");
|
||||
);
|
||||
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
|
||||
|
||||
// Consequence preview row: follows the keyboard-selected action by default,
|
||||
// arrow navigation, and hover; each pill carries a native title fallback.
|
||||
const consequence = () => document.querySelector(".approval-consequence__text")?.textContent ?? null;
|
||||
eq(consequence(), "Allow this call only; the next one asks again.", "consequence row previews the selected action by default");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(consequence(), "Allow matching calls until this session ends; resets on restart.", "arrow navigation moves the consequence preview");
|
||||
|
||||
const pills = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
eq(pills[2]?.getAttribute("title"), "Save a persistent allow rule; future sessions stop asking too.", "action pills carry a native title fallback");
|
||||
await act(async () => {
|
||||
pills[3].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(consequence(), "Reject this call; the model sees the refusal and continues.", "hover overrides the consequence preview");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
@@ -260,8 +360,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 +380,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);
|
||||
@@ -111,6 +111,29 @@ console.log("\nask card layout");
|
||||
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
|
||||
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
|
||||
|
||||
// Detail preview row: shows the first described option by default, follows
|
||||
// hover, and each option carries a native-title fallback for truncation.
|
||||
const detail = document.querySelector(".ask-shelf__detail") as HTMLElement | null;
|
||||
if (!detail) throw new Error("ask detail preview row did not render");
|
||||
eq(
|
||||
detail.querySelector(".ask-shelf__detail-text")?.textContent,
|
||||
ask.questions[0].options[0].description,
|
||||
"detail row previews the first described option by default",
|
||||
);
|
||||
|
||||
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
eq(optionButtons[1]?.getAttribute("title"), ask.questions[0].options[1].description, "options carry a native title fallback");
|
||||
|
||||
await act(async () => {
|
||||
optionButtons[1].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
document.querySelector(".ask-shelf__detail-text")?.textContent,
|
||||
ask.questions[0].options[1].description,
|
||||
"detail row follows the hovered option",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Stand-in for bundler-handled asset imports (.svg) in tsx-run tests; see the
|
||||
// resolve hook in history-recovery-copies.test.tsx.
|
||||
export default "";
|
||||
@@ -0,0 +1,85 @@
|
||||
// Run: tsx src/__tests__/bridge-breadcrumbs.test.ts
|
||||
|
||||
import { snapshotBreadcrumbs } from "../lib/breadcrumbs";
|
||||
import { app } from "../lib/bridge";
|
||||
|
||||
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 ensureWindow() {
|
||||
if (typeof window === "undefined") {
|
||||
(globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
|
||||
}
|
||||
}
|
||||
|
||||
function newBreadcrumbMessages(since: number, cat: string): string[] {
|
||||
return snapshotBreadcrumbs()
|
||||
.slice(since)
|
||||
.filter((crumb) => crumb.cat === cat)
|
||||
.map((crumb) => crumb.msg);
|
||||
}
|
||||
|
||||
console.log("\nbridge breadcrumbs");
|
||||
|
||||
const previousPerformance = (globalThis as { performance?: Performance }).performance;
|
||||
let now = 0;
|
||||
(globalThis as Record<string, unknown>).performance = { now: () => now };
|
||||
|
||||
ensureWindow();
|
||||
const previousGo = window.go;
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
async IsMainWindowMaximised() {
|
||||
now = 42;
|
||||
return true;
|
||||
},
|
||||
async CheckUpdate() {
|
||||
now = 125;
|
||||
throw new Error("offline");
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
};
|
||||
|
||||
const successStart = snapshotBreadcrumbs().length;
|
||||
now = 10;
|
||||
await app.IsMainWindowMaximised();
|
||||
const successBridge = newBreadcrumbMessages(successStart, "bridge");
|
||||
ok(
|
||||
successBridge.includes("window IsMainWindowMaximised") &&
|
||||
successBridge.includes("window IsMainWindowMaximised done ms=32"),
|
||||
"records bridge start and completion timing breadcrumbs",
|
||||
);
|
||||
|
||||
const errorStart = snapshotBreadcrumbs().length;
|
||||
now = 100;
|
||||
try {
|
||||
await app.CheckUpdate();
|
||||
} catch {
|
||||
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
|
||||
}
|
||||
const errorBridge = newBreadcrumbMessages(errorStart, "bridge");
|
||||
const bridgeErrors = newBreadcrumbMessages(errorStart, "bridge.error");
|
||||
ok(errorBridge.includes("update CheckUpdate"), "records bridge start breadcrumb before failed calls");
|
||||
ok(bridgeErrors.includes("CheckUpdate ms=25"), "records bridge failure timing breadcrumbs");
|
||||
|
||||
window.go = previousGo;
|
||||
if (previousPerformance) {
|
||||
(globalThis as Record<string, unknown>).performance = previousPerformance;
|
||||
} else {
|
||||
delete (globalThis as { performance?: Performance }).performance;
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -257,7 +257,7 @@ console.log("\ncomposer goal toggle");
|
||||
turnStartAt: Date.now(),
|
||||
});
|
||||
|
||||
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
|
||||
const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stopButton) throw new Error("composer stop button did not render");
|
||||
|
||||
await act(async () => {
|
||||
@@ -643,7 +643,7 @@ console.log("\ncomposer goal toggle");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
|
||||
eq(textarea.placeholder, "Running — type guidance, Enter adds it to the queue", "running composer explains queued guidance input");
|
||||
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
|
||||
ok(sendButton.disabled === true, "running steer button stays disabled without input");
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Run: tsx src/__tests__/composer-run-strip.test.tsx
|
||||
//
|
||||
// The run state lives inside the composer card (no floating pill, no layout
|
||||
// jump), stop has a fixed home next to send, and a pending approval/ask shifts
|
||||
// the strip into a waiting state instead of a ticking "working" spinner.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } 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 flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
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.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const calls = { cancel: 0 };
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
onSend: () => {},
|
||||
onCancel: () => {
|
||||
calls.cancel += 1;
|
||||
return undefined;
|
||||
},
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: () => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, calls, rerender: paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer run strip");
|
||||
|
||||
// Idle: no strip, no stop button, plain send arrow.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer();
|
||||
|
||||
eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip");
|
||||
eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
|
||||
ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
|
||||
eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
const strip = document.querySelector(".composer-card .composer-run-strip");
|
||||
ok(strip !== null, "running strip renders inside the composer card");
|
||||
const ticker = strip?.querySelector(".composer-run-strip__text");
|
||||
eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
|
||||
const live = strip?.querySelector(".sr-only[role=\"status\"]");
|
||||
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
|
||||
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
|
||||
|
||||
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stop) throw new Error("running composer stop button did not render");
|
||||
await act(async () => {
|
||||
stop.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.cancel, 1, "stop button next to send cancels the turn");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Waiting on approval: strip switches to the waiting state and stops ticking.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
|
||||
|
||||
const strip = document.querySelector(".composer-run-strip");
|
||||
ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting");
|
||||
const text = strip?.querySelector(".composer-run-strip__text");
|
||||
eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval");
|
||||
eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
|
||||
eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
|
||||
ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
|
||||
|
||||
const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
|
||||
ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
|
||||
|
||||
await rerender({ pendingApprovalLabel: null, pendingAsk: true });
|
||||
eq(
|
||||
document.querySelector(".composer-run-strip__text")?.textContent,
|
||||
"Waiting for your answer",
|
||||
"pending ask question shows the ask waiting state",
|
||||
);
|
||||
ok(
|
||||
modeButtons.every((b) => b.disabled),
|
||||
"approval bar stays disabled for non-approval reasons",
|
||||
);
|
||||
|
||||
await rerender({ pendingAsk: false, disabled: false });
|
||||
ok(
|
||||
document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
|
||||
"resolving the prompt returns the strip to the ticking spinner",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Cancel restores queued guidance: stop means "stop acting", never "discard
|
||||
// what I typed".
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({
|
||||
running: true,
|
||||
turnStartAt: Date.now(),
|
||||
guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"],
|
||||
});
|
||||
|
||||
ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf");
|
||||
|
||||
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stop) throw new Error("stop button did not render");
|
||||
await act(async () => {
|
||||
stop.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.cancel, 1, "stop cancels the running turn");
|
||||
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
|
||||
eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft");
|
||||
eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Waiting on the user pauses the ticker clock: elapsed time means model time.
|
||||
{
|
||||
const dom = installDom();
|
||||
const start = Date.now() - 30000;
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
|
||||
|
||||
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2400));
|
||||
});
|
||||
await rerender({ pendingApprovalLabel: null, disabled: false });
|
||||
|
||||
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
|
||||
ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
|
||||
ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Resize consistency: --composer-height always carries the logical height in
|
||||
// every writer (React render, live drag, keyboard), with the run strip's
|
||||
// reservation isolated in a CSS calc — so dragging a resized composer during a
|
||||
// running turn cannot flash-shrink the card.
|
||||
{
|
||||
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
|
||||
ok(
|
||||
stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"),
|
||||
"resized card height combines logical height and strip reservation in CSS",
|
||||
);
|
||||
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
handle.focus();
|
||||
handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const card = document.querySelector(".composer-card") as HTMLElement;
|
||||
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
|
||||
|
||||
// Drag while running: the live writer stays in logical-height space.
|
||||
await act(async () => {
|
||||
handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 }));
|
||||
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
|
||||
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
|
||||
|
||||
await rerender({ running: false, turnStartAt: undefined });
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
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");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildPerformancePayload,
|
||||
formatPerformanceContext,
|
||||
globalCrashReportReason,
|
||||
installPerformancePressureMonitor,
|
||||
normalizeCrashError,
|
||||
parseReportedPerf,
|
||||
performanceLabelForReason,
|
||||
@@ -151,6 +152,42 @@ eq(shouldPromptForPerformanceLabel(false, 5 * 60_000, false), false, "respects t
|
||||
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, true), false, "never prompts while the window is hidden");
|
||||
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false, false), false, "never prompts while unfocused");
|
||||
|
||||
{
|
||||
let interval: (() => void) | undefined;
|
||||
let now = 0;
|
||||
let promptPainted = false;
|
||||
const previousWindow = (globalThis as any).window;
|
||||
const previousDocument = (globalThis as any).document;
|
||||
const previousPerformance = (globalThis as any).performance;
|
||||
const previousPerformanceObserver = (globalThis as any).PerformanceObserver;
|
||||
(globalThis as any).performance = { now: () => now };
|
||||
(globalThis as any).window = {
|
||||
runtime: {},
|
||||
setInterval: (cb: () => void) => {
|
||||
interval = cb;
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
(globalThis as any).document = {
|
||||
visibilityState: "visible",
|
||||
hasFocus: () => true,
|
||||
addEventListener: () => {},
|
||||
getElementById: () => {
|
||||
promptPainted = true;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
(globalThis as any).PerformanceObserver = undefined;
|
||||
installPerformancePressureMonitor();
|
||||
now = 26_000;
|
||||
interval?.();
|
||||
eq(promptPainted, false, "first post-grace event-loop tick primes without reporting startup backlog");
|
||||
(globalThis as any).window = previousWindow;
|
||||
(globalThis as any).document = previousDocument;
|
||||
(globalThis as any).performance = previousPerformance;
|
||||
(globalThis as any).PerformanceObserver = previousPerformanceObserver;
|
||||
}
|
||||
|
||||
const reportedPerf = serializeReportedPerf(new Set(["performance.lag"]), "abc123");
|
||||
eq([...parseReportedPerf(reportedPerf, "abc123")], ["performance.lag"], "round-trips reported labels for the same build");
|
||||
eq([...parseReportedPerf(reportedPerf, "def456")], [], "re-surfaces reported labels on a new build");
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Run: tsx src/__tests__/history-recovery-copies.test.tsx
|
||||
//
|
||||
// Recovery-copy bulk actions in HistoryPanel: the history view sweeps idle
|
||||
// copies into the trash (skipping current/open ones), the trash view purges
|
||||
// them, and both flows keep normal sessions untouched.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { SessionMeta } from "../lib/types";
|
||||
|
||||
// HistoryPanel transitively imports Welcome's SVG wordmark; tsx has no asset
|
||||
// loader, so redirect .svg specifiers to an empty-string module stub, the way
|
||||
// Vite would default-export a URL.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".svg")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
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 (JSON.stringify(actual) === JSON.stringify(expected)) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
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(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
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);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
return dom;
|
||||
}
|
||||
|
||||
const now = 1_750_000_000_000;
|
||||
|
||||
function session(overrides: Partial<SessionMeta> & { path: string }): SessionMeta {
|
||||
return {
|
||||
preview: "session preview",
|
||||
turns: 3,
|
||||
createdAt: now - 3_600_000,
|
||||
lastActivityAt: now,
|
||||
modTime: now,
|
||||
current: false,
|
||||
open: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(props: Record<string, unknown>) {
|
||||
const { HistoryPanel } = await import("../components/HistoryPanel");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<HistoryPanel
|
||||
running={false}
|
||||
onResume={() => {}}
|
||||
onPreview={async () => []}
|
||||
onDelete={() => {}}
|
||||
onRename={() => {}}
|
||||
onClose={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers(30);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function findButton(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === text) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
}
|
||||
|
||||
async function click(button: HTMLButtonElement) {
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await flushTimers(20);
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nhistory panel recovery-copy bulk actions");
|
||||
|
||||
// History view: the sweep button trashes idle recovery copies only.
|
||||
{
|
||||
const dom = installDom();
|
||||
const deleted: string[][] = [];
|
||||
const purged: string[][] = [];
|
||||
const root = await renderPanel({
|
||||
kind: "history",
|
||||
sessions: [
|
||||
session({ path: "/s/normal.jsonl" }),
|
||||
session({ path: "/s/idle-recovery-0123456789abcdef.jsonl", recovered: true }),
|
||||
session({ path: "/s/open-recovery-0123456789abcdef.jsonl", recovered: true, open: true }),
|
||||
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
|
||||
],
|
||||
onDeleteMany: (paths: string[]) => deleted.push(paths),
|
||||
onPurgeAll: (paths: string[]) => purged.push(paths),
|
||||
});
|
||||
|
||||
const sweep = findButton("Trash recovery copies");
|
||||
ok(Boolean(sweep), "history view shows the trash-recovery-copies button");
|
||||
if (sweep) {
|
||||
await click(sweep); // arm
|
||||
const confirm = findButton("Confirm trash copies");
|
||||
ok(Boolean(confirm), "sweep arms into a confirm step");
|
||||
if (confirm) await click(confirm);
|
||||
}
|
||||
eq(deleted, [["/s/idle-recovery-0123456789abcdef.jsonl"]], "sweep trashes only idle recovery copies");
|
||||
eq(purged, [], "history sweep never purges");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// History view: no idle copies → no sweep button.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = await renderPanel({
|
||||
kind: "history",
|
||||
sessions: [
|
||||
session({ path: "/s/normal.jsonl" }),
|
||||
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
|
||||
],
|
||||
onDeleteMany: () => {},
|
||||
});
|
||||
ok(!findButton("Trash recovery copies"), "history view hides the sweep button when every copy is live");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Trash view: clear copies purges recovery copies, empty trash stays separate.
|
||||
{
|
||||
const dom = installDom();
|
||||
const purged: string[][] = [];
|
||||
const root = await renderPanel({
|
||||
kind: "trash",
|
||||
sessions: [
|
||||
session({ path: "/t/normal.jsonl", deletedAt: now }),
|
||||
session({ path: "/t/a-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
|
||||
session({ path: "/t/b-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
|
||||
],
|
||||
onRestore: () => {},
|
||||
onPurge: () => {},
|
||||
onPurgeAll: (paths: string[]) => purged.push(paths),
|
||||
});
|
||||
|
||||
const clear = findButton("Clear copies");
|
||||
ok(Boolean(clear), "trash view shows the clear-copies button");
|
||||
if (clear) {
|
||||
await click(clear); // arm
|
||||
const confirm = findButton("Confirm clear copies");
|
||||
ok(Boolean(confirm), "clear copies arms into a confirm step");
|
||||
if (confirm) await click(confirm);
|
||||
}
|
||||
eq(
|
||||
purged,
|
||||
[["/t/a-recovery-0123456789abcdef.jsonl", "/t/b-recovery-0123456789abcdef.jsonl"]],
|
||||
"clear copies purges every trashed recovery copy and nothing else",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -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,31 @@
|
||||
// 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("\nquiet recovery prompt privacy");
|
||||
|
||||
ok(!appSource.includes("banner--recovery"), "App does not render a persistent recovery banner");
|
||||
ok(!appSource.includes("recoveryDigest"), "App does not expose recovery digest through recovery prompts");
|
||||
ok(!appSource.includes("recoveryParentId"), "App does not expose parent session id through recovery prompts");
|
||||
ok(!appSource.includes("recoveryBannerTitle"), "App does not build a detailed recovery tooltip");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,62 @@
|
||||
// Run: tsx src/__tests__/recovery-quiet-notifications.test.ts
|
||||
//
|
||||
// Recovery copies remain protected by the backend, but the desktop UI should
|
||||
// not interrupt users with recovery lifecycle banners, toasts, or transcript
|
||||
// notices.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
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");
|
||||
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
|
||||
|
||||
console.log("\nquiet recovery notifications");
|
||||
|
||||
ok(!appSource.includes("banner--recovery"), "App does not render a recovery banner");
|
||||
ok(!appSource.includes("recovery.toast"), "App does not show a recovery-created toast");
|
||||
ok(!appSource.includes("onSessionRecoveryFailed"), "App does not show recovery failure toasts");
|
||||
ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery acknowledgement controls");
|
||||
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
|
||||
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
|
||||
|
||||
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText))"), "raw quiet notices are skipped before localization");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text))"), "localized quiet notices are skipped before rendering");
|
||||
|
||||
const removedPromptKeys = [
|
||||
"recovery.open",
|
||||
"recovery.toast",
|
||||
"recovery.failedLease",
|
||||
"recovery.failedUnavailable",
|
||||
"recovery.banner",
|
||||
"recovery.bannerCompare",
|
||||
"recovery.bannerDismiss",
|
||||
"recovery.openOriginalFailed",
|
||||
];
|
||||
for (const [name, dict] of [["en", en], ["zh", zh], ["zh-TW", zhTW]] as const) {
|
||||
for (const key of removedPromptKeys) {
|
||||
ok(!(key in dict), `${name} locale omits ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -41,6 +42,19 @@ function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installCanvasMock(win: Window) {
|
||||
Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value(type: string) {
|
||||
if (type !== "2d") return null;
|
||||
return {
|
||||
font: "",
|
||||
measureText: () => ({ width: 0 }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
@@ -146,11 +160,32 @@ 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,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
installCanvasMock(dom.window as unknown as Window);
|
||||
(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;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Run: tsx src/__tests__/sound.test.ts
|
||||
|
||||
import { attentionChimeEventKey, clearAttentionChimeKeys, shouldPlayAttentionChimeForEvent } from "../lib/sound";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsound notifications");
|
||||
|
||||
{
|
||||
eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key");
|
||||
eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key");
|
||||
eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key");
|
||||
eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys");
|
||||
}
|
||||
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play");
|
||||
}
|
||||
|
||||
{
|
||||
// The dedupe set stays bounded: after many unique prompts it self-prunes
|
||||
// while still deduping recently seen ids.
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen);
|
||||
}
|
||||
eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning");
|
||||
}
|
||||
|
||||
{
|
||||
// Runtime rebuild clears dedupe keys so reissued ids chime again.
|
||||
const seen = new Set<string>();
|
||||
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen);
|
||||
shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen);
|
||||
clearAttentionChimeKeys(seen, "tab-a");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear");
|
||||
clearAttentionChimeKeys(seen);
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key");
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`sound notifications: ${failed} failed, ${passed} passed`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`sound notifications: ${passed} passed`);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Run: tsx src/__tests__/tool-card-error-details.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ToolCard } from "../components/ToolCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, 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 flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
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.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
console.log("\ntool card error details");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const error = "evidence 1: verification command \"Select-String -Path @(...long...)\" has no matching successful receipt — cite the command exactly as it ran in the session; commands that ran: [\"unique-command-tail\"] — pick the matching one and retry complete_step";
|
||||
const item: ToolItem = {
|
||||
kind: "tool",
|
||||
id: "complete-step-error",
|
||||
name: "complete_step",
|
||||
args: "",
|
||||
readOnly: true,
|
||||
status: "error",
|
||||
output: `error: ${error}`,
|
||||
error,
|
||||
durationMs: 62,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LocaleProvider, null,
|
||||
React.createElement(ToolCard, { item }),
|
||||
),
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(document.querySelectorAll(".code-block").length, 0, "duplicate error output is not rendered as a code block");
|
||||
ok(document.querySelector(".tool__err-summary")?.textContent?.includes("verification command has no matching successful receipt"), "compact error summary is visible");
|
||||
ok(!document.querySelector(".tool__err-details"), "full error details are hidden by default");
|
||||
ok(!document.body.textContent?.includes("unique-command-tail"), "long receipt list is not visible before expansion");
|
||||
|
||||
const toggle = document.querySelector(".tool__err-toggle") as HTMLButtonElement | null;
|
||||
if (!toggle) throw new Error("error details toggle did not render");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
ok(document.querySelector(".tool__err-details")?.textContent?.includes("unique-command-tail"), "full error details render after expansion");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -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,142 @@ 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 can be normalized without exposing 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 can be normalized",
|
||||
);
|
||||
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 can be normalized",
|
||||
);
|
||||
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 can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"adopted transcript notice can be normalized",
|
||||
);
|
||||
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 can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("The assistant answered before taking action; asking it to use the required tools."),
|
||||
"The assistant answered before taking action; asking it to use the required tools.",
|
||||
"canonical backend notice is routed through the locale dictionary",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("background export failed: needs attention"),
|
||||
"Background export needs attention.",
|
||||
"dynamic background job 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, 0, "recovery conflict notices stay silent in the live transcript");
|
||||
eq(s.seq, 0, "silent recovery notices do not consume sequence ids");
|
||||
|
||||
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 quietLifecycleMessages = [
|
||||
{ level: "info", text: "guardian enabled · model=guardian-test" },
|
||||
{ level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
|
||||
{ level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
|
||||
{ level: "info", text: "settings applied: session refreshed after the lease was released" },
|
||||
{ level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
|
||||
] as const;
|
||||
let s = initialState;
|
||||
for (const message of quietLifecycleMessages) {
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
|
||||
}
|
||||
eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
|
||||
eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
|
||||
|
||||
const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
|
||||
const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
|
||||
eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
|
||||
eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
|
||||
}
|
||||
|
||||
{
|
||||
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: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
|
||||
{ role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
|
||||
{ role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
|
||||
{ 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 lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
|
||||
const users = hydrated.items.filter((item) => item.kind === "user");
|
||||
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
|
||||
eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
|
||||
eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
|
||||
eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
|
||||
}
|
||||
|
||||
{
|
||||
const hydrated = historyMessagesToItems([{ role: "notice", level: "warn", content: "short notice", detail: "historical diagnostic" }], "h");
|
||||
const notice = hydrated.items.find((item) => item.kind === "notice" && item.text === "short notice");
|
||||
eq(notice?.kind === "notice" && notice.detail, "historical diagnostic", "history notices preserve expandable detail text");
|
||||
}
|
||||
|
||||
{
|
||||
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
@@ -239,6 +408,12 @@ console.log("\nuse controller meta");
|
||||
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
|
||||
}
|
||||
|
||||
{
|
||||
const s = reducer(initialState, { type: "event", e: { kind: "notice", level: "warn", text: "short notice", detail: "verbose diagnostic" } });
|
||||
const notice = s.items.find((item) => item.kind === "notice" && item.text === "short notice");
|
||||
eq(notice?.kind === "notice" && notice.detail, "verbose diagnostic", "runtime notices preserve expandable detail text");
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "history_page",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
import { DUR_FAST } from "../lib/gsapAnimations";
|
||||
import {
|
||||
FileReferenceMenu,
|
||||
@@ -28,7 +27,93 @@ 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";
|
||||
}
|
||||
|
||||
const APPROVAL_MODE_RANK: Record<ToolApprovalMode, number> = { ask: 0, auto: 1, yolo: 2 };
|
||||
|
||||
export 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");
|
||||
case "exit_plan_mode":
|
||||
return t("approval.toolLabelExitPlan");
|
||||
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({
|
||||
@@ -40,6 +125,7 @@ export function ApprovalModal({
|
||||
cwd,
|
||||
insertRequest,
|
||||
onRevisionActiveChange,
|
||||
toolApprovalMode,
|
||||
}: {
|
||||
approval: WireApproval;
|
||||
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
|
||||
@@ -49,21 +135,35 @@ export function ApprovalModal({
|
||||
cwd?: string;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
onRevisionActiveChange?: (active: boolean) => void;
|
||||
toolApprovalMode?: ToolApprovalMode;
|
||||
}) {
|
||||
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";
|
||||
// Switching the approval segmented control to a more permissive mode does not
|
||||
// resolve an already-pending request; say so on the card instead of leaving
|
||||
// the user to wonder why the switch "did nothing".
|
||||
const initialToolApprovalModeRef = useRef(toolApprovalMode);
|
||||
const approvalModeRelaxed =
|
||||
!isPlanApproval &&
|
||||
toolApprovalMode !== undefined &&
|
||||
initialToolApprovalModeRef.current !== undefined &&
|
||||
APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current];
|
||||
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("");
|
||||
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
|
||||
// Action index currently hovered/focused; the consequence preview row
|
||||
// prefers it over the keyboard-selected action.
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -100,6 +200,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,16 +210,22 @@ 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);
|
||||
setRevisionText("");
|
||||
setDetailsOpen(showToolDetailsByDefault);
|
||||
setSelectedIndex(isPlanApproval ? 1 : 0);
|
||||
playAttentionChime();
|
||||
setHoverIndex(null);
|
||||
}, [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 +246,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 +255,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) {
|
||||
@@ -293,6 +401,26 @@ export function ApprovalModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Descriptor list mirrors the action buttons below; the consequence row
|
||||
// previews what the hovered (or keyboard-selected) action will actually do,
|
||||
// in the quick-pick style the ask card uses for option descriptions.
|
||||
const toolActions: { key: string; label: string; desc: string; run: () => void }[] = [
|
||||
{ key: "1", label: t("approval.allowOnce"), desc: t("approval.allowOnceDesc"), run: () => onAnswer(true, false, false) },
|
||||
...(isFreshHumanApproval
|
||||
? hasFreshSessionGrant
|
||||
? [
|
||||
{ key: "2", label: t("approval.allowSandboxEscapeSession"), desc: t("approval.allowSandboxEscapeSessionDesc"), run: () => onAnswer(true, true, false) },
|
||||
{ key: "3", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
|
||||
]
|
||||
: [{ key: "2", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }]
|
||||
: [
|
||||
{ key: "2", label: t("approval.allowRuleSession"), desc: t("approval.allowRuleSessionDesc"), run: () => onAnswer(true, true, false) },
|
||||
{ key: "3", label: t("approval.allowRulePersistent"), desc: t("approval.allowRulePersistentDesc"), run: () => onAnswer(true, true, true) },
|
||||
{ key: "4", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
|
||||
]),
|
||||
];
|
||||
const previewAction = toolActions[hoverIndex ?? selectedIndex] ?? null;
|
||||
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
@@ -316,26 +444,46 @@ export function ApprovalModal({
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<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} />
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
</>
|
||||
)}
|
||||
{toolActions.map((action, index) => (
|
||||
<PromptAction
|
||||
key={action.key}
|
||||
keyLabel={action.key}
|
||||
label={action.label}
|
||||
onClick={() => answerWithExit(action.run)}
|
||||
selected={selectedIndex === index}
|
||||
title={action.desc}
|
||||
onHoverChange={(hovering) =>
|
||||
setHoverIndex((current) => (hovering ? index : current === index ? null : current))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
previewAction && (
|
||||
<div className="approval-consequence">
|
||||
<span className="approval-consequence__label">{previewAction.label}</span>
|
||||
<span className="approval-consequence__text">{previewAction.desc}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
{/* Guard the whole block: PromptShelf only renders its body when children
|
||||
are truthy, and a fragment of two false branches would still count. */}
|
||||
{(approvalModeRelaxed || detailsOpen) && (
|
||||
<>
|
||||
{approvalModeRelaxed && (
|
||||
<div className="approval-mode-hint">{t("approval.modeSwitchPendingHint")}</div>
|
||||
)}
|
||||
</div>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PromptShelf>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
|
||||
import { PromptAction, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
|
||||
// AskCard renders the `ask` tool as a compact prompt shelf near the composer. It
|
||||
// walks multi-question asks one at a time; single-select answers advance
|
||||
@@ -24,6 +23,8 @@ export function AskCard({
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
// Option label currently hovered/focused; drives the detail preview row.
|
||||
const [hoverLabel, setHoverLabel] = useState<string | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const advanceTimer = useRef<number | null>(null);
|
||||
@@ -40,8 +41,11 @@ export function AskCard({
|
||||
setCustom({});
|
||||
setCustomOpen(false);
|
||||
setActive(0);
|
||||
setHoverLabel(null);
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
playAttentionChime();
|
||||
// The attention chime plays from the global runtime event stream (App.tsx),
|
||||
// keyed by prompt id — not here. A mount-time chime would double-fire for
|
||||
// the active tab and stay silent for background tabs.
|
||||
}, [ask.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,6 +77,7 @@ export function AskCard({
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOpen(false);
|
||||
setHoverLabel(null);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,6 +161,23 @@ export function AskCard({
|
||||
[active, custom, questions, sel],
|
||||
);
|
||||
|
||||
// Grid cells truncate descriptions to one line; this row previews the full
|
||||
// description of the hovered/focused option, falling back to the latest
|
||||
// selected option and then the first described option so it never blanks.
|
||||
const detailOption = useMemo(() => {
|
||||
if (!q) return null;
|
||||
const withDescription = (label?: string | null) =>
|
||||
label ? q.options.find((option) => option.label === label && option.description) : undefined;
|
||||
const hovered = withDescription(hoverLabel);
|
||||
if (hovered) return hovered;
|
||||
const selectedLabels = sel[q.id] ?? [];
|
||||
for (let i = selectedLabels.length - 1; i >= 0; i -= 1) {
|
||||
const chosen = withDescription(selectedLabels[i]);
|
||||
if (chosen) return chosen;
|
||||
}
|
||||
return q.options.find((option) => option.description) ?? null;
|
||||
}, [hoverLabel, q, sel]);
|
||||
|
||||
if (!q) return null;
|
||||
|
||||
return (
|
||||
@@ -196,11 +218,23 @@ export function AskCard({
|
||||
description={o.description}
|
||||
onClick={() => toggle(q, o.label)}
|
||||
selected={on}
|
||||
title={o.description || undefined}
|
||||
onHoverChange={(hovering) =>
|
||||
setHoverLabel((current) => (hovering ? o.label : current === o.label ? null : current))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
detailOption && (
|
||||
<div className="ask-shelf__detail">
|
||||
<span className="ask-shelf__detail-label">{detailOption.label}</span>
|
||||
<span className="ask-shelf__detail-text">{detailOption.description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
quickActions={
|
||||
<>
|
||||
{active > 0 && (
|
||||
|
||||
@@ -51,6 +51,9 @@ const LONG_PASTE_MIN_CHARS = 2000;
|
||||
const LONG_PASTE_MIN_LINES = 20;
|
||||
const COMPOSER_MIN_HEIGHT = 104;
|
||||
const COMPOSER_MAX_HEIGHT = 360;
|
||||
// Height reserved for the in-card run strip while a turn runs; applied via a
|
||||
// CSS calc so --composer-height always stays in "logical height" space.
|
||||
const COMPOSER_RUN_STRIP_RESERVED = 30;
|
||||
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
|
||||
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
|
||||
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
@@ -255,6 +258,14 @@ function composerMaxHeight(): number {
|
||||
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
|
||||
}
|
||||
|
||||
// The rendered card includes the run strip while a turn runs; subtract it to
|
||||
// recover the user's logical height when measuring from the DOM.
|
||||
function composerLogicalHeight(card: HTMLElement): number {
|
||||
const strip = card.querySelector(".composer-run-strip");
|
||||
const stripHeight = strip ? strip.getBoundingClientRect().height : 0;
|
||||
return card.getBoundingClientRect().height - stripHeight;
|
||||
}
|
||||
|
||||
function clampComposerHeight(height: number): number {
|
||||
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
|
||||
}
|
||||
@@ -449,6 +460,8 @@ export function Composer({
|
||||
turnStartAt,
|
||||
turnTokens,
|
||||
retry,
|
||||
pendingApprovalLabel,
|
||||
pendingAsk = false,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
@@ -492,6 +505,11 @@ export function Composer({
|
||||
turnStartAt?: number;
|
||||
turnTokens?: number;
|
||||
retry?: { attempt: number; max: number };
|
||||
// Resolved label of the tool waiting for approval (null/undefined when none);
|
||||
// shifts the run strip into its waiting state so the ticking spinner does not
|
||||
// claim the model is working while a prompt is blocked on the user.
|
||||
pendingApprovalLabel?: string | null;
|
||||
pendingAsk?: boolean;
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
@@ -1536,7 +1554,19 @@ export function Composer({
|
||||
const handleCancel = () => {
|
||||
const restored = onCancel();
|
||||
if (goalModeOn && activeGoal) onClearGoal();
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
// Queued guidance the model never consumed would otherwise vanish when the
|
||||
// cancelled turn ends (the running→false effect clears the shelf). Fold it
|
||||
// back into the draft: cancelling means "stop acting", not "discard what I
|
||||
// typed" — the same contract onCancel already honors for un-sent text.
|
||||
const queued = pendingGuidance.map((item) => item.text).filter((part) => part.trim() !== "");
|
||||
if (queued.length === 0) {
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
return;
|
||||
}
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
const base = typeof restored === "string" ? restored : text;
|
||||
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
|
||||
};
|
||||
|
||||
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
|
||||
@@ -1637,7 +1667,7 @@ export function Composer({
|
||||
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = composerHeight ?? card.getBoundingClientRect().height;
|
||||
const startHeight = composerHeight ?? composerLogicalHeight(card);
|
||||
let nextHeight = clampComposerHeight(startHeight);
|
||||
let moved = false;
|
||||
card.style.setProperty("--composer-height", `${nextHeight}px`);
|
||||
@@ -1675,7 +1705,7 @@ export function Composer({
|
||||
|
||||
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const card = composerCardRef.current;
|
||||
const current = composerHeight ?? card?.getBoundingClientRect().height ?? COMPOSER_MIN_HEIGHT;
|
||||
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
|
||||
const step = e.shiftKey ? 32 : 16;
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
|
||||
@@ -2006,7 +2036,18 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
|
||||
// When the run strip is visible inside a user-resized card, the card grows
|
||||
// by the strip's reserved height so the meta row stays fully visible.
|
||||
// --composer-height carries only the user's logical height; the reservation
|
||||
// is a separate variable consumed by the CSS calc, so the live resize drag
|
||||
// (which writes raw logical heights) stays consistent with this render path.
|
||||
const showRunStrip = Boolean(retry || running);
|
||||
const composerCardStyle = composerHeight === null
|
||||
? undefined
|
||||
: ({
|
||||
"--composer-height": `${composerHeight}px`,
|
||||
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
|
||||
} as CSSProperties);
|
||||
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
: undefined;
|
||||
@@ -2054,17 +2095,45 @@ export function Composer({
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
});
|
||||
};
|
||||
const runActivity = retry
|
||||
// Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
|
||||
// The waiting states replace the whimsical ticker — while a prompt is blocked
|
||||
// on the user, the strip must say so instead of counting "working" seconds.
|
||||
const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null;
|
||||
// The pending approval itself disables the composer; keep the approval bar
|
||||
// usable in exactly that case so the mode can still be changed mid-prompt.
|
||||
const approvalBarDisabled = Boolean(disabled) && !pendingApprovalLabel;
|
||||
// Waiting on the user (approval/ask) is not model work: pause the ticker's
|
||||
// clock while a prompt is blocked so the elapsed time means "model time",
|
||||
// matching what the waiting strip promises. Retries keep counting — they are
|
||||
// system time, not user time. Accumulated in state via the effect cleanup so
|
||||
// leaving the waiting state re-renders with the corrected time immediately.
|
||||
const [waitAccumMs, setWaitAccumMs] = useState(0);
|
||||
useEffect(() => {
|
||||
setWaitAccumMs(0);
|
||||
}, [turnStartAt]);
|
||||
useEffect(() => {
|
||||
if (!waitingPrompt) return;
|
||||
const since = Date.now();
|
||||
return () => setWaitAccumMs((total) => total + (Date.now() - since));
|
||||
}, [waitingPrompt]);
|
||||
const runStateText = retry
|
||||
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
|
||||
: running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
: waitingPrompt === "approval"
|
||||
? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
|
||||
: waitingPrompt === "ask"
|
||||
? t("composer.runWaitingAsk")
|
||||
: running
|
||||
? t("composer.runAnnounceRunning")
|
||||
: null;
|
||||
const runTicker = !retry && !waitingPrompt && running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
|
||||
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
|
||||
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
|
||||
@@ -2322,20 +2391,6 @@ export function Composer({
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{runActivity && (
|
||||
<div className="composer-toolbar composer-toolbar--status-only">
|
||||
<div className="composer-runstatus" role="status" aria-live="polite">
|
||||
<span className="composer-runstatus__dot" />
|
||||
<span className="composer-runstatus__text">{runActivity}</span>
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button className="composer-runstatus__stop" type="button" onClick={handleCancel}>
|
||||
<Square size={10} fill="currentColor" />
|
||||
<span>{t("composer.stopShort")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingGuidance.length > 0 && (
|
||||
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
|
||||
<div className="composer-guidance-head">
|
||||
@@ -2484,7 +2539,7 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
ref={composerCardRef}
|
||||
style={composerCardStyle}
|
||||
>
|
||||
@@ -2502,6 +2557,17 @@ export function Composer({
|
||||
onKeyDown={onComposerResizeKeyDown}
|
||||
onDoubleClick={resetComposerHeight}
|
||||
/>
|
||||
{runStateText && (
|
||||
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
|
||||
<span className="composer-run-strip__dot" aria-hidden="true" />
|
||||
{/* The ticker re-renders every second; keep it out of the accessibility
|
||||
tree and announce only the stable state text via the live region. */}
|
||||
<span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
|
||||
{runTicker ?? runStateText}
|
||||
</span>
|
||||
<span className="sr-only" role="status">{runStateText}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
|
||||
onDrop={onDrop}
|
||||
@@ -2543,6 +2609,18 @@ export function Composer({
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{running && (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--stop"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
aria-label={t("composer.stop")}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
@@ -2550,7 +2628,7 @@ export function Composer({
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -2634,13 +2712,17 @@ export function Composer({
|
||||
)}
|
||||
</div>
|
||||
<div className="composer-meta__control composer-meta__control--approval">
|
||||
{/* A pending tool approval disables the composer, but the approval
|
||||
bar stays usable so mode changes remain possible mid-prompt;
|
||||
the approval card explains that the pending request still needs
|
||||
an explicit decision. */}
|
||||
<div className="composer-modebar composer-modebar--approval" data-mode={toolApprovalMode} title={t("composer.accessMenuTitle")}>
|
||||
<span className="composer-modebar__thumb" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("ask")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "ask"}
|
||||
title={t("composer.accessAskTitle")}
|
||||
>
|
||||
@@ -2651,7 +2733,7 @@ export function Composer({
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("auto")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "auto"}
|
||||
title={t("composer.accessAutoTitle")}
|
||||
>
|
||||
@@ -2662,7 +2744,7 @@ export function Composer({
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("yolo")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "yolo"}
|
||||
title={t("composer.accessYoloTitle")}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@ export function HistoryPanel({
|
||||
onRestore,
|
||||
onPurge,
|
||||
onPurgeAll,
|
||||
onDeleteMany,
|
||||
onClose,
|
||||
}: {
|
||||
kind?: "history" | "trash";
|
||||
@@ -40,6 +41,7 @@ export function HistoryPanel({
|
||||
onRestore?: (path: string) => void;
|
||||
onPurge?: (path: string) => void;
|
||||
onPurgeAll?: (paths: string[]) => void;
|
||||
onDeleteMany?: (paths: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tr = useT();
|
||||
@@ -56,7 +58,7 @@ export function HistoryPanel({
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | { kind: "clearRecovery" } | null
|
||||
>(null);
|
||||
const [preview, setPreview] = useState<{
|
||||
path: string;
|
||||
@@ -144,15 +146,33 @@ export function HistoryPanel({
|
||||
return [s.title, s.preview, s.path, s.topicTitle, s.workspaceRoot].some((part) => (part ?? "").toLowerCase().includes(q));
|
||||
});
|
||||
}, [dateFilter, isTrash, query, scopeFilter, sessions, statusFilter]);
|
||||
// Recovery copies are bulk-actionable in both views: trash purges them for
|
||||
// good, history sweeps them into the trash. The history sweep skips copies
|
||||
// that are current or open in a tab — an adopted copy is someone's live
|
||||
// conversation. Counting from `sessions` (not the filtered list) keeps the
|
||||
// sweep exhaustive even while a search or filter is active.
|
||||
const recoveryCopyPaths = useMemo(
|
||||
() => sessions.filter((s) => s.recovered && (isTrash || (!s.current && !s.open))).map((s) => s.path),
|
||||
[isTrash, sessions],
|
||||
);
|
||||
const recoveryCopyCount = recoveryCopyPaths.length;
|
||||
const displayedSessions = useMemo(
|
||||
() =>
|
||||
isTrash
|
||||
? filteredSessions
|
||||
: [...filteredSessions.filter((s) => !s.recovered), ...filteredSessions.filter((s) => s.recovered)],
|
||||
[filteredSessions, isTrash],
|
||||
);
|
||||
|
||||
// Sessions arrive newest-first; bucket consecutive ones under a day heading
|
||||
// (Today / Yesterday / a date) while preserving that order.
|
||||
const groups: { label: string; items: SessionMeta[] }[] = [];
|
||||
for (const s of filteredSessions) {
|
||||
const groups: { label: string; items: SessionMeta[]; recovered: boolean }[] = [];
|
||||
for (const s of displayedSessions) {
|
||||
const recovered = !isTrash && Boolean(s.recovered);
|
||||
const label = dayLabel(sessionTimeForGrouping(s, isTrash));
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.label === label) last.items.push(s);
|
||||
else groups.push({ label, items: [s] });
|
||||
if (last && last.label === label && last.recovered === recovered) last.items.push(s);
|
||||
else groups.push({ label, items: [s], recovered });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,19 +188,19 @@ export function HistoryPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(null);
|
||||
if (filteredSessions.length === 0) {
|
||||
if (displayedSessions.length === 0) {
|
||||
if (preview) setPreview(null);
|
||||
return;
|
||||
}
|
||||
if (preview && filteredSessions.some((s) => s.path === preview.path)) return;
|
||||
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
|
||||
if (preview && displayedSessions.some((s) => s.path === preview.path)) return;
|
||||
const first = displayedSessions.find((s) => !s.current) ?? displayedSessions[0];
|
||||
void loadPreview(first);
|
||||
}, [filteredSessions, loadPreview, preview]);
|
||||
}, [displayedSessions, loadPreview, preview]);
|
||||
|
||||
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
|
||||
const selectedSession = useMemo(
|
||||
() => (preview ? filteredSessions.find((s) => s.path === preview.path) ?? null : null),
|
||||
[filteredSessions, preview],
|
||||
() => (preview ? displayedSessions.find((s) => s.path === preview.path) ?? null : null),
|
||||
[displayedSessions, preview],
|
||||
);
|
||||
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
|
||||
event.preventDefault();
|
||||
@@ -207,6 +227,13 @@ export function HistoryPanel({
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clear" });
|
||||
};
|
||||
const armClearRecoveryCopies = () => {
|
||||
if (recoveryCopyCount === 0 || (!isTrash && running)) return;
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clearRecovery" });
|
||||
};
|
||||
const closeHistoryMenus = () => {
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
@@ -226,6 +253,12 @@ export function HistoryPanel({
|
||||
closeHistoryMenus();
|
||||
onPurgeAll?.(paths);
|
||||
};
|
||||
const clearRecoveryCopies = () => {
|
||||
const paths = recoveryCopyPaths;
|
||||
closeHistoryMenus();
|
||||
if (isTrash) onPurgeAll?.(paths);
|
||||
else onDeleteMany?.(paths);
|
||||
};
|
||||
const sessionMenuItems: ContextMenuItem[] = menuSession
|
||||
? isTrash
|
||||
? [
|
||||
@@ -292,30 +325,52 @@ export function HistoryPanel({
|
||||
]
|
||||
: [];
|
||||
const trashBlankMenuItems: ContextMenuItem[] =
|
||||
menuConfirmTarget?.kind === "clear"
|
||||
menuConfirmTarget?.kind === "clearRecovery"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
key: "clear-recovery-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
label: tr("history.confirmClearRecoveryCopies"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
onSelect: clearRecoveryCopies,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
: menuConfirmTarget?.kind === "clear"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
},
|
||||
]
|
||||
: [
|
||||
...(recoveryCopyCount > 0
|
||||
? [
|
||||
{
|
||||
key: "clear-recovery",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearRecoveryCopiesMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clearRecovery" }),
|
||||
} as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
const actionConfirmDelete =
|
||||
selectedSession && menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === selectedSession.path;
|
||||
const actionConfirmPurge =
|
||||
selectedSession && menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === selectedSession.path;
|
||||
const actionConfirmClearTrash = isTrash && menuConfirmTarget?.kind === "clear";
|
||||
const actionConfirmClearRecovery = menuConfirmTarget?.kind === "clearRecovery";
|
||||
|
||||
const openSelected = () => {
|
||||
if (!selectedSession || running || isTrash) return;
|
||||
@@ -356,6 +411,18 @@ export function HistoryPanel({
|
||||
{!isTrash && running && <div className="management-modal__summary history-modal__summary">{tr("history.readOnlyHint")}</div>}
|
||||
</div>
|
||||
<div className="management-modal__actions history-modal__actions">
|
||||
{recoveryCopyCount > 0 && (
|
||||
<button
|
||||
className={`chip history-clear${actionConfirmClearRecovery ? " history-clear--confirm" : ""}`}
|
||||
type="button"
|
||||
disabled={!isTrash && running}
|
||||
onClick={actionConfirmClearRecovery ? clearRecoveryCopies : armClearRecoveryCopies}
|
||||
>
|
||||
{isTrash
|
||||
? tr(actionConfirmClearRecovery ? "history.confirmClearRecoveryCopies" : "history.clearRecoveryCopies")
|
||||
: tr(actionConfirmClearRecovery ? "history.confirmTrashRecoveryCopies" : "history.trashRecoveryCopies")}
|
||||
</button>
|
||||
)}
|
||||
{isTrash && sessions.length > 0 && (
|
||||
<button
|
||||
className={`chip history-clear${actionConfirmClearTrash ? " history-clear--confirm" : ""}`}
|
||||
@@ -426,9 +493,9 @@ export function HistoryPanel({
|
||||
<div className="mem-empty">{tr("history.noResults")}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<section className="mem-section" key={g.label}>
|
||||
<section className="mem-section" key={`${g.recovered ? "recovered" : "normal"}-${g.label}`}>
|
||||
<div className="mem-section__title hist-group__title">
|
||||
<span>{g.label}</span>
|
||||
<span>{g.recovered ? `${tr("history.recoveryCopiesGroup")} · ${g.label}` : g.label}</span>
|
||||
<span className="hist-group__count">{g.items.length}</span>
|
||||
</div>
|
||||
{g.items.map((s) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -10,6 +10,7 @@ export function PromptShelf({
|
||||
actions,
|
||||
children,
|
||||
crumbs,
|
||||
note,
|
||||
quickActions,
|
||||
headerActions,
|
||||
barRef,
|
||||
@@ -24,6 +25,9 @@ export function PromptShelf({
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
crumbs?: ReactNode;
|
||||
// Rendered between the actions grid and the quick actions; used for
|
||||
// focus-following detail previews and similar footnotes.
|
||||
note?: ReactNode;
|
||||
quickActions?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
barRef?: RefObject<HTMLDivElement | null>;
|
||||
@@ -52,6 +56,7 @@ export function PromptShelf({
|
||||
{crumbs}
|
||||
{children && <div className="prompt-shelf__body">{children}</div>}
|
||||
{actions && <div className="prompt-shelf__actions">{actions}</div>}
|
||||
{note && <div className="prompt-shelf__footnote">{note}</div>}
|
||||
{quickActions && <div className="prompt-shelf__quick-actions">{quickActions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +97,8 @@ export function PromptAction({
|
||||
description,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
title,
|
||||
onHoverChange,
|
||||
primary = false,
|
||||
selected = false,
|
||||
quiet = false,
|
||||
@@ -102,6 +109,11 @@ export function PromptAction({
|
||||
description?: ReactNode;
|
||||
onClick: () => void;
|
||||
ariaLabel?: string;
|
||||
// Native tooltip fallback for truncated descriptions.
|
||||
title?: string;
|
||||
// Fires on mouse enter/focus (true) and mouse leave/blur (false) so the
|
||||
// parent can drive a focus-following detail preview.
|
||||
onHoverChange?: (hovering: boolean) => void;
|
||||
primary?: boolean;
|
||||
selected?: boolean;
|
||||
quiet?: boolean;
|
||||
@@ -121,6 +133,11 @@ export function PromptAction({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
|
||||
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
|
||||
onFocus={onHoverChange ? () => onHoverChange(true) : undefined}
|
||||
onBlur={onHoverChange ? () => onHoverChange(false) : undefined}
|
||||
>
|
||||
{keyLabel && <span className="prompt-action__key">{keyLabel}</span>}
|
||||
{hasCopy && (
|
||||
|
||||
@@ -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";
|
||||
@@ -16,6 +16,8 @@ const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "rev
|
||||
|
||||
/** Lines shown by default in a shell output block before the "show all" button. */
|
||||
const SHELL_PREVIEW_LINES = 10;
|
||||
const ERROR_SUMMARY_MAX_CHARS = 140;
|
||||
const ERROR_DETAILS_THRESHOLD = 220;
|
||||
|
||||
function pretty(json: string): string {
|
||||
try {
|
||||
@@ -30,6 +32,41 @@ function formatToolDuration(ms?: number): string {
|
||||
return `${Math.round(ms)} ms`;
|
||||
}
|
||||
|
||||
function normalizeErrorText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").trim();
|
||||
}
|
||||
|
||||
function withoutErrorPrefix(text: string): string {
|
||||
return normalizeErrorText(text).replace(/^error:\s*/i, "");
|
||||
}
|
||||
|
||||
function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
|
||||
if (!output || !error) return false;
|
||||
const normalizedOutput = normalizeErrorText(output);
|
||||
const normalizedError = normalizeErrorText(error);
|
||||
if (!normalizedOutput || !normalizedError) return false;
|
||||
return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
|
||||
}
|
||||
|
||||
function summarizeToolError(error: string, receiptMismatchText: string): string {
|
||||
const text = withoutErrorPrefix(error);
|
||||
if (!text) return "";
|
||||
if (/has no matching successful receipt/i.test(text)) {
|
||||
return receiptMismatchText;
|
||||
}
|
||||
const firstLine = text.split("\n")[0]?.trim() ?? "";
|
||||
if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
|
||||
return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
|
||||
}
|
||||
|
||||
function errorNeedsDetails(error: string, summary: string): boolean {
|
||||
const normalizedError = withoutErrorPrefix(error);
|
||||
if (!normalizedError) return false;
|
||||
return normalizedError.includes("\n") ||
|
||||
normalizedError.length > ERROR_DETAILS_THRESHOLD ||
|
||||
(summary !== "" && normalizedError !== summary);
|
||||
}
|
||||
|
||||
/** Returns the first n lines of text and the total line count. */
|
||||
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
|
||||
const lines = text.split("\n");
|
||||
@@ -60,12 +97,14 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [showErrorDetails, setShowErrorDetails] = useState(false);
|
||||
// Lazy-load full tool data from the backend when the card is expanded and
|
||||
// the in-memory copy was archived for memory efficiency.
|
||||
const [fullData, setFullData] = useState<{ args: string; output?: string } | null>(null);
|
||||
const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
|
||||
const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
|
||||
const effectiveOutput = fullData?.output ?? item.output;
|
||||
const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
|
||||
const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
|
||||
const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
|
||||
const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
|
||||
@@ -78,12 +117,15 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
// else folds its args/output away by default. Open while running so the
|
||||
// user sees progress; closed by default once settled.
|
||||
const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
|
||||
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!effectiveOutput || hasArchivedOnDemandBody);
|
||||
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
|
||||
|
||||
// Shell output: split into preview + "show all" toggle.
|
||||
const shellOutput = item.isShell && effectiveOutput ? effectiveOutput : null;
|
||||
const shellOutput = item.isShell && displayOutput ? displayOutput : null;
|
||||
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
|
||||
const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error);
|
||||
const errorText = item.error ? normalizeErrorText(item.error) : "";
|
||||
const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
|
||||
const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
|
||||
useEffect(() => {
|
||||
if (!open || !item.dataArchived || fullData || !tabId) return;
|
||||
let cancelled = false;
|
||||
@@ -111,7 +153,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
|
||||
|
||||
const duration = item.status === "running" ? "" : formatToolDuration(item.durationMs);
|
||||
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, effectiveOutput, item.error));
|
||||
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? errorSummary : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error));
|
||||
|
||||
// GSAP-driven collapse/expand for tool body
|
||||
const toolBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -127,7 +169,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>}
|
||||
@@ -202,16 +249,36 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
{!shellPreview && hasArgsOrOutput && (
|
||||
<>
|
||||
{effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
|
||||
{effectiveOutput && (
|
||||
{displayOutput && (
|
||||
<>
|
||||
<CodeViewer value={effectiveOutput} maxHeight={280} />
|
||||
<CodeViewer value={displayOutput} maxHeight={280} />
|
||||
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.error && <div className="tool__err">{item.error}</div>}
|
||||
{errorText && (
|
||||
<div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
|
||||
{hasErrorDetails ? (
|
||||
<>
|
||||
<div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tool__err-toggle"
|
||||
onClick={() => setShowErrorDetails((value) => !value)}
|
||||
aria-expanded={showErrorDetails}
|
||||
>
|
||||
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
|
||||
<span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
|
||||
</button>
|
||||
{showErrorDetails && <div className="tool__err-details">{errorText}</div>}
|
||||
</>
|
||||
) : (
|
||||
errorText
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -585,7 +587,7 @@ export function Transcript({
|
||||
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
|
||||
break;
|
||||
case "phase": out.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
|
||||
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
|
||||
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -940,7 +949,7 @@ function WarmTurnItems({
|
||||
break;
|
||||
}
|
||||
case "phase": nodes.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
|
||||
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
|
||||
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
@@ -1259,11 +1268,20 @@ function PhaseCard({ text }: { text: string }) {
|
||||
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
|
||||
}
|
||||
|
||||
function NoticeCard({ level, text }: { level: NoticeItem["level"]; text: string }) {
|
||||
function NoticeCard({ level, text, detail }: { level: NoticeItem["level"]; text: string; detail?: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`notice-line notice-line--${level}`} data-entrance="true">
|
||||
<span className="notice-line__icon">{level === "warn" ? "⚠ " : "ℹ "}</span>
|
||||
<span className="notice-line__text">{text}</span>
|
||||
<div className="notice-line__text">
|
||||
{text}
|
||||
{detail ? (
|
||||
<details className="notice-line__details">
|
||||
<summary>{t("notice.details")}</summary>
|
||||
<div>{detail}</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -574,6 +574,16 @@ export function onFilesDropped(cb: (paths: string[]) => void): () => void {
|
||||
|
||||
// onReady subscribes to the agent:ready event fired when boot.Build completes.
|
||||
// The frontend re-fetches Meta/Context/History when this lands.
|
||||
// onRuntimeRebuilt fires when a tab's controller is replaced in place
|
||||
// (model/effort/token-mode switch, clear-while-running). The rebuilt
|
||||
// controller restarts prompt ids, so per-tab id-keyed state must reset.
|
||||
export function onRuntimeRebuilt(cb: (tabId?: string) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("runtime:rebuilt", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onReady(cb: (tabId?: string) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("agent:ready", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
|
||||
@@ -627,6 +637,11 @@ function bridgeBreadcrumb(method: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number): number {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
return Math.max(0, Math.round(now - startedAt));
|
||||
}
|
||||
|
||||
export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
get(_t, prop) {
|
||||
const target = realApp() ?? getMock();
|
||||
@@ -635,18 +650,26 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
return (...args: unknown[]) => {
|
||||
const method = String(prop);
|
||||
const crumb = bridgeBreadcrumb(method);
|
||||
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
|
||||
if (crumb) addBreadcrumb("bridge", crumb);
|
||||
try {
|
||||
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
|
||||
if (result && typeof (result as Promise<unknown>).then === "function") {
|
||||
return (result as Promise<unknown>).catch((err) => {
|
||||
if (crumb) addBreadcrumb("bridge.error", method);
|
||||
throw err;
|
||||
});
|
||||
return (result as Promise<unknown>).then(
|
||||
(value) => {
|
||||
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
|
||||
return value;
|
||||
},
|
||||
(err) => {
|
||||
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (crumb) addBreadcrumb("bridge.error", method);
|
||||
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -719,12 +742,14 @@ 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" | "notice" {
|
||||
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";
|
||||
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
@@ -819,8 +844,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 +897,8 @@ function makeMockApp(): AppBindings {
|
||||
const freshMock = scenario === "fresh";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const sandboxEscapeMock = scenario === "sandbox_escape";
|
||||
const noticePreviewMock = scenario === "notice";
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
@@ -980,7 +1007,7 @@ function makeMockApp(): AppBindings {
|
||||
// Mutable so delete/rename are observable in browser dev.
|
||||
const sessions: SessionMeta[] = [
|
||||
{ path: "/mock/sessions/a.jsonl", preview: "fix the login bug in auth.go", turns: 12, createdAt: t0 - 2 * day, lastActivityAt: t0 - 3_600_000, modTime: t0 - 3_600_000, current: true, open: true },
|
||||
{ path: "/mock/sessions/b.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true },
|
||||
{ path: "/mock/sessions/b-recovery-0123456789abcdef.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true, recovered: true },
|
||||
{ path: "/mock/sessions/c.jsonl", preview: "write the README and badges", turns: 8, createdAt: t0 - 4 * day, lastActivityAt: t0 - day - 3_600_000, modTime: t0 - day - 3_600_000, current: false, open: false },
|
||||
{ path: "/mock/sessions/d.jsonl", preview: "explain the plugin host design", turns: 3, createdAt: t0 - 5 * day, lastActivityAt: t0 - 4 * day, modTime: t0 - 4 * day, current: false, open: false },
|
||||
];
|
||||
@@ -1031,6 +1058,7 @@ function makeMockApp(): AppBindings {
|
||||
scope: "global",
|
||||
topicId: "topic_product",
|
||||
topicTitle: t("mock.trashGlobalProductTitle"),
|
||||
recovered: true,
|
||||
},
|
||||
];
|
||||
if (freshMock) {
|
||||
@@ -1555,7 +1583,28 @@ function makeMockApp(): AppBindings {
|
||||
setMockTabRunning(currentMockTurnTabId(), false);
|
||||
emit({ kind: "turn_done" });
|
||||
};
|
||||
let mockTabs: TabMeta[] = freshMock ? [
|
||||
let mockTabs: TabMeta[] = noticePreviewMock ? [
|
||||
{
|
||||
id: "tab_notice_preview",
|
||||
scope: "project",
|
||||
workspaceRoot: "~/projects/reasonix",
|
||||
workspaceName: "reasonix",
|
||||
workspacePath: "~/projects/reasonix",
|
||||
gitBranch: "codex/compact-chat-notices-i18n",
|
||||
topicId: "topic_notice_preview",
|
||||
topicTitle: "Compact notice preview",
|
||||
projectColor: "green",
|
||||
label: "DeepSeek-R1",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "~/projects/reasonix",
|
||||
},
|
||||
] : freshMock ? [
|
||||
{
|
||||
id: "tab_global",
|
||||
scope: "global",
|
||||
@@ -1634,6 +1683,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 +1796,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 +1930,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.
|
||||
|
||||
@@ -635,6 +635,7 @@ export function installPerformancePressureMonitor() {
|
||||
const isFocused = () => typeof document === "undefined" || document.hasFocus?.() !== false;
|
||||
let visibleSince = isHidden() ? Number.POSITIVE_INFINITY : startedAt;
|
||||
let expected = performance.now() + 1000;
|
||||
let eventLoopLagPrimed = false;
|
||||
|
||||
const pastGrace = () => performance.now() >= graceUntil;
|
||||
const inspectLongTasks = () => {
|
||||
@@ -651,6 +652,7 @@ export function installPerformancePressureMonitor() {
|
||||
longTasks.length = 0;
|
||||
lagSamples.length = 0;
|
||||
expected = performance.now() + 1000;
|
||||
eventLoopLagPrimed = false;
|
||||
if (!isHidden()) visibleSince = performance.now();
|
||||
});
|
||||
}
|
||||
@@ -673,9 +675,17 @@ export function installPerformancePressureMonitor() {
|
||||
|
||||
window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
if (!pastGrace()) {
|
||||
expected = now + 1000;
|
||||
return;
|
||||
}
|
||||
if (!eventLoopLagPrimed) {
|
||||
expected = now + 1000;
|
||||
eventLoopLagPrimed = true;
|
||||
return;
|
||||
}
|
||||
const lagMs = Math.max(0, now - expected);
|
||||
expected = now + 1000;
|
||||
if (!pastGrace()) return;
|
||||
if (!shouldRecordEventLoopLagSample(isHidden(), now - visibleSince, isFocused())) return;
|
||||
lagSamples.push(lagMs);
|
||||
if (lagSamples.length > MAX_LAG_SAMPLES) lagSamples.shift();
|
||||
|
||||
@@ -114,7 +114,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
|
||||
}, [locale]);
|
||||
|
||||
// setPref updates only the live UI; persistence is handled by desktop config.
|
||||
|
||||
@@ -156,3 +156,56 @@ export function playAttentionChime(): void {
|
||||
void playWav(pref, 0.25, playSynthAttention);
|
||||
}
|
||||
}
|
||||
|
||||
export type AttentionChimeEvent = {
|
||||
kind?: string;
|
||||
tabId?: string;
|
||||
approval?: { id?: string };
|
||||
ask?: { id?: string };
|
||||
};
|
||||
|
||||
export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined {
|
||||
if (event.kind === "approval_request" && event.approval?.id) return `approval:${event.tabId ?? ""}:${event.approval.id}`;
|
||||
if (event.kind === "ask_request" && event.ask?.id) return `ask:${event.tabId ?? ""}:${event.ask.id}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per
|
||||
// prompt, so the set only ever grows; past the cap the oldest half is dropped
|
||||
// (insertion order) — replay dedupe only needs to cover recently replayed
|
||||
// prompts, not the whole session history.
|
||||
const attentionChimeSeenCap = 512;
|
||||
|
||||
// clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval
|
||||
// and ask ids are per-controller counters starting at "1", so a rebuilt
|
||||
// controller (model/effort/settings switch) reissues ids an earlier prompt on
|
||||
// the same tab already used — without this, the first prompt after a rebuild
|
||||
// is misread as a replay and stays silent. A ready event without a tab id
|
||||
// (settings rebuilds emit tab-less ready) clears everything: over-clearing
|
||||
// only re-chimes a replayed pending prompt, which is a desirable reminder,
|
||||
// while under-clearing mutes a live prompt.
|
||||
export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void {
|
||||
if (tabId === undefined || tabId === "") {
|
||||
seen.clear();
|
||||
return;
|
||||
}
|
||||
for (const key of [...seen]) {
|
||||
if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) {
|
||||
seen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean {
|
||||
const key = attentionChimeEventKey(event);
|
||||
if (!key || seen.has(key)) return false;
|
||||
if (seen.size >= attentionChimeSeenCap) {
|
||||
let drop = seen.size - attentionChimeSeenCap / 2;
|
||||
for (const k of seen) {
|
||||
if (drop-- <= 0) break;
|
||||
seen.delete(k);
|
||||
}
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface MemoryCompilerStats {
|
||||
export interface WireEvent {
|
||||
kind: EventKind;
|
||||
text?: string;
|
||||
detail?: string;
|
||||
reasoning?: string;
|
||||
memoryCitations?: MemoryCitation[];
|
||||
memoryCompiler?: MemoryCompilerStats;
|
||||
@@ -324,6 +325,7 @@ export interface ChangedFileInfo {
|
||||
export interface HistoryMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
detail?: string;
|
||||
submitText?: string;
|
||||
checkpointTurn?: number;
|
||||
createdAt?: number;
|
||||
|
||||
@@ -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 {
|
||||
@@ -53,7 +53,7 @@ export type Item =
|
||||
| { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] }
|
||||
| { kind: "phase"; id: string; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
@@ -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, m.detail);
|
||||
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 ?? "", e.detail);
|
||||
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,275 @@ 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] });
|
||||
}
|
||||
const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
|
||||
if (backgroundJob) {
|
||||
return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
|
||||
}
|
||||
const canonicalNoticeKey = backendNoticeKey(msg);
|
||||
if (canonicalNoticeKey) {
|
||||
return t(canonicalNoticeKey);
|
||||
}
|
||||
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 backendNoticeKey(msg: string): DictKey | "" {
|
||||
switch (msg) {
|
||||
case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.":
|
||||
return "notice.finalReadiness";
|
||||
case "No visible answer was produced; asking the assistant to respond again.":
|
||||
return "notice.emptyFinal";
|
||||
case "The assistant answered before taking action; asking it to use the required tools.":
|
||||
return "notice.executorHandoff";
|
||||
case "Tool round limit reached; asking the assistant to summarize progress.":
|
||||
return "notice.toolBudget";
|
||||
case "The assistant is stuck retrying a blocked action; asking it to change approach.":
|
||||
return "notice.loopGuard";
|
||||
case "Context is getting large; preserving cache until cleanup is needed.":
|
||||
return "notice.contextLarge";
|
||||
case "Context cleanup skipped for now.":
|
||||
return "notice.contextCleanupSkipped";
|
||||
case "Automatic context cleanup paused because the context window is too small.":
|
||||
return "notice.contextCleanupPaused";
|
||||
case "Context was compacted without a generated summary.":
|
||||
return "notice.compactionNoSummary";
|
||||
case "Planning mode enabled for this multi-step task.":
|
||||
return "notice.autoPlanEnabled";
|
||||
case "Plan detection requested a plan.":
|
||||
return "notice.autoPlanRequested";
|
||||
case "Plan detection was uncertain; using the fallback planner heuristic.":
|
||||
return "notice.autoPlanFallback";
|
||||
case "Goal is not ready to complete yet; continuing the remaining work.":
|
||||
return "notice.goalNotReady";
|
||||
case "Goal still has unfinished task state; continuing the remaining work.":
|
||||
return "notice.goalUnfinished";
|
||||
case "AutoResearch status update failed.":
|
||||
return "notice.autoresearchStatusFailed";
|
||||
case "AutoResearch task marked blocked.":
|
||||
return "notice.autoresearchBlocked";
|
||||
case "Job artifact migration failed.":
|
||||
return "notice.jobArtifactMigrationFailed";
|
||||
case "Background job teardown timed out.":
|
||||
return "notice.jobTeardownTimeout";
|
||||
case "Some plan-mode tool settings were ignored.":
|
||||
return "notice.planModeToolSettingsIgnored";
|
||||
case "Some plan-mode command settings were ignored.":
|
||||
return "notice.planModeCommandSettingsIgnored";
|
||||
case "Config migration did not complete.":
|
||||
return "notice.configMigrationIncomplete";
|
||||
case "Selected model is missing its API key.":
|
||||
return "notice.modelMissingApiKey";
|
||||
case "An MCP server failed to start.":
|
||||
return "notice.mcpServerFailed";
|
||||
case "Some MCP servers failed to start; run /mcp for details.":
|
||||
return "notice.mcpServersFailed";
|
||||
case "Guardian was disabled because its model was not found.":
|
||||
return "notice.guardianModelMissing";
|
||||
case "Guardian was disabled because it could not start.":
|
||||
return "notice.guardianStartFailed";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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 quietTranscriptNoticeKey(text: string): string {
|
||||
const recovery = recoveryNoticeDedupeKey(text);
|
||||
if (recovery) return recovery;
|
||||
|
||||
const msg = text.trim();
|
||||
if (/^guardian enabled · model=.+$/i.test(msg)) {
|
||||
return "startup:guardian-enabled";
|
||||
}
|
||||
if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
|
||||
return "startup:mcp-failures";
|
||||
}
|
||||
const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
|
||||
if (directMCPFailure) {
|
||||
const name = directMCPFailure[1].toLowerCase();
|
||||
if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
|
||||
return "startup:mcp-failure";
|
||||
}
|
||||
}
|
||||
if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
|
||||
return "startup:plugin-demote";
|
||||
}
|
||||
if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
|
||||
return "settings:deferred-refresh-applied";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string): { items: Item[]; seq: number } {
|
||||
if (quietTranscriptNoticeKey(rawText)) {
|
||||
return { items, seq };
|
||||
}
|
||||
const text = localizedBackendNoticeText(rawText);
|
||||
if (quietTranscriptNoticeKey(text)) {
|
||||
return { items, seq };
|
||||
}
|
||||
const trimmedDetail = detail?.trim();
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}) }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail);
|
||||
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 +1238,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 +1269,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 +1657,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 +1680,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 +1757,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 +1783,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 +1793,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 +1804,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 +2042,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 +2072,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
|
||||
@@ -470,7 +471,10 @@ export const en = {
|
||||
"composer.send": "Send (Enter)",
|
||||
"composer.queueGuidance": "Add to guidance queue (Enter)",
|
||||
"composer.steer": "Add guidance to current turn (Enter)",
|
||||
"composer.steerPlaceholder": "Add guidance to the queue...",
|
||||
"composer.steerPlaceholder": "Running — type guidance, Enter adds it to the queue",
|
||||
"composer.runWaitingApproval": "Waiting for your approval — {tool}",
|
||||
"composer.runWaitingAsk": "Waiting for your answer",
|
||||
"composer.runAnnounceRunning": "Reasonix is working",
|
||||
"composer.guidanceQueue": "Queued guidance",
|
||||
"composer.guidanceCount": "Queued guidance {n}",
|
||||
"composer.guidanceRemaining": "{n} more queued",
|
||||
@@ -531,14 +535,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})",
|
||||
@@ -649,6 +676,13 @@ export const en = {
|
||||
"approval.sendRevision": "Send update",
|
||||
"approval.exitPlan": "Exit plan",
|
||||
"approval.toolPending": "Allow tool call",
|
||||
"approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.",
|
||||
"approval.toolLabelExitPlan": "Plan approval",
|
||||
"approval.allowOnceDesc": "Allow this call only; the next one asks again.",
|
||||
"approval.allowRuleSessionDesc": "Allow matching calls until this session ends; resets on restart.",
|
||||
"approval.allowRulePersistentDesc": "Save a persistent allow rule; future sessions stop asking too.",
|
||||
"approval.denyDesc": "Reject this call; the model sees the refusal and continues.",
|
||||
"approval.allowSandboxEscapeSessionDesc": "Run follow-up commands in the real environment for the rest of this session.",
|
||||
"approval.details": "Details",
|
||||
"approval.hideDetails": "Hide",
|
||||
"approval.deny": "Deny",
|
||||
@@ -663,6 +697,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",
|
||||
@@ -701,6 +758,12 @@ export const en = {
|
||||
"history.clearTrash": "Empty trash",
|
||||
"history.confirmClearTrash": "Confirm empty trash",
|
||||
"history.clearTrashMenu": "Empty trash…",
|
||||
"history.clearRecoveryCopies": "Clear copies",
|
||||
"history.confirmClearRecoveryCopies": "Confirm clear copies",
|
||||
"history.clearRecoveryCopiesMenu": "Clear recovery copies…",
|
||||
"history.trashRecoveryCopies": "Trash recovery copies",
|
||||
"history.confirmTrashRecoveryCopies": "Confirm trash copies",
|
||||
"history.recoveryCopiesGroup": "Recovery copies",
|
||||
"history.moveToTrash": "Move to trash",
|
||||
"history.confirmMoveToTrash": "Confirm move to trash",
|
||||
"history.restore": "Restore",
|
||||
@@ -732,6 +795,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",
|
||||
@@ -740,14 +804,10 @@ export const en = {
|
||||
// session recovery
|
||||
"recovery.branch": "Conflict copy",
|
||||
"recovery.badge": "Copy",
|
||||
"recovery.open": "Open conflict copy",
|
||||
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
|
||||
"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 +1602,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 +1723,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 +1807,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}",
|
||||
@@ -1810,6 +1885,34 @@ export const en = {
|
||||
"transcript.processedDuration": "Processed {s}s",
|
||||
"notice.info": "Notice",
|
||||
"notice.warning": "Warning",
|
||||
"notice.details": "Details",
|
||||
"notice.finalReadiness": "Task status needs one more check; asking the assistant to finish or explain what is blocking it.",
|
||||
"notice.emptyFinal": "No visible answer was produced; asking the assistant to respond again.",
|
||||
"notice.executorHandoff": "The assistant answered before taking action; asking it to use the required tools.",
|
||||
"notice.toolBudget": "Tool round limit reached; asking the assistant to summarize progress.",
|
||||
"notice.loopGuard": "The assistant is stuck retrying a blocked action; asking it to change approach.",
|
||||
"notice.contextLarge": "Context is getting large; preserving cache until cleanup is needed.",
|
||||
"notice.contextCleanupSkipped": "Context cleanup skipped for now.",
|
||||
"notice.contextCleanupPaused": "Automatic context cleanup paused because the context window is too small.",
|
||||
"notice.compactionNoSummary": "Context was compacted without a generated summary.",
|
||||
"notice.autoPlanEnabled": "Planning mode enabled for this multi-step task.",
|
||||
"notice.autoPlanRequested": "Plan detection requested a plan.",
|
||||
"notice.autoPlanFallback": "Plan detection was uncertain; using the fallback planner heuristic.",
|
||||
"notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.",
|
||||
"notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch status update failed.",
|
||||
"notice.autoresearchBlocked": "AutoResearch task marked blocked.",
|
||||
"notice.backgroundJobFailed": "Background {kind} needs attention.",
|
||||
"notice.jobArtifactMigrationFailed": "Job artifact migration failed.",
|
||||
"notice.jobTeardownTimeout": "Background job teardown timed out.",
|
||||
"notice.planModeToolSettingsIgnored": "Some plan-mode tool settings were ignored.",
|
||||
"notice.planModeCommandSettingsIgnored": "Some plan-mode command settings were ignored.",
|
||||
"notice.configMigrationIncomplete": "Config migration did not complete.",
|
||||
"notice.modelMissingApiKey": "Selected model is missing its API key.",
|
||||
"notice.mcpServerFailed": "An MCP server failed to start.",
|
||||
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
|
||||
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
|
||||
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
|
||||
"questionNav.label": "Question navigation",
|
||||
"questionNav.progress": "Question {current} / {total}",
|
||||
"questionNav.jump": "Jump to question {n}",
|
||||
@@ -1825,6 +1928,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 +1949,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",
|
||||
@@ -1855,8 +1960,12 @@ export const en = {
|
||||
// tool card summaries
|
||||
"tool.stepOne": "{n} step",
|
||||
"tool.stepOther": "{n} steps",
|
||||
"tool.error": "error",
|
||||
"tool.errorReceiptMismatch": "verification command has no matching successful receipt",
|
||||
"tool.truncated": "output truncated",
|
||||
"tool.showAllLines": "show all {n} lines",
|
||||
"tool.showErrorDetails": "show error details",
|
||||
"tool.hideErrorDetails": "hide error details",
|
||||
"tool.readCount": "Read {n} files",
|
||||
"tool.searchCount": "Search {n} files",
|
||||
"tool.otherReadCount": "{n} read calls",
|
||||
@@ -2027,6 +2136,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",
|
||||
|
||||
// 頂欄
|
||||
@@ -349,7 +350,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.send": "傳送(Enter)",
|
||||
"composer.queueGuidance": "加入引導佇列(Enter)",
|
||||
"composer.steer": "追加到目前任務(Enter)",
|
||||
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
|
||||
"composer.steerPlaceholder": "正在執行——輸入補充指示,Enter 加入佇列",
|
||||
"composer.runWaitingApproval": "等待你核准 — {tool}",
|
||||
"composer.runWaitingAsk": "等待你回答",
|
||||
"composer.runAnnounceRunning": "正在執行",
|
||||
"composer.guidanceQueue": "待處理引導",
|
||||
"composer.guidanceCount": "待處理引導 {n}",
|
||||
"composer.guidanceRemaining": "還有 {n} 條",
|
||||
@@ -385,13 +389,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})",
|
||||
@@ -501,6 +523,13 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"approval.sendRevision": "傳送修改意見",
|
||||
"approval.exitPlan": "退出計畫",
|
||||
"approval.toolPending": "允許工具呼叫",
|
||||
"approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。",
|
||||
"approval.toolLabelExitPlan": "計畫確認",
|
||||
"approval.allowOnceDesc": "僅允許本次呼叫,下次仍會詢問。",
|
||||
"approval.allowRuleSessionDesc": "本會話內符合的呼叫不再詢問,重啟後失效。",
|
||||
"approval.allowRulePersistentDesc": "儲存為持久允許規則,之後的會話也不再詢問。",
|
||||
"approval.denyDesc": "拒絕本次呼叫,模型會收到拒絕結果並繼續。",
|
||||
"approval.allowSandboxEscapeSessionDesc": "本會話後續命令改在真實環境執行,跳過沙箱。",
|
||||
"approval.details": "詳情",
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒絕",
|
||||
@@ -515,6 +544,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": "需要你決定",
|
||||
@@ -544,6 +596,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "確認清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.clearRecoveryCopies": "清理副本",
|
||||
"history.confirmClearRecoveryCopies": "確認清理副本",
|
||||
"history.clearRecoveryCopiesMenu": "清理恢復副本…",
|
||||
"history.trashRecoveryCopies": "副本移入回收站",
|
||||
"history.confirmTrashRecoveryCopies": "確認移入回收站",
|
||||
"history.recoveryCopiesGroup": "恢復副本",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "確認移入回收站",
|
||||
"history.restore": "還原",
|
||||
@@ -575,6 +633,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.failedOpenProject": "無法開啟會話,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "無法開啟會話",
|
||||
"history.failedOpenGlobal": "無法開啟全域會話",
|
||||
"history.missingWorkspaceRoot": "無法開啟會話:缺少工作區路徑。",
|
||||
"history.selectSession": "選擇一個會話以預覽。",
|
||||
"history.trashSessionActions": "回收站會話操作",
|
||||
"history.historySessionActions": "歷史會話操作",
|
||||
@@ -583,14 +642,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
// 會話恢復
|
||||
"recovery.branch": "衝突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "開啟衝突副本",
|
||||
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
|
||||
"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 +1005,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 +1126,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": "重新整理中…",
|
||||
@@ -1155,6 +1221,34 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"transcript.processedDuration": "已處理 {s}s",
|
||||
"notice.info": "提示",
|
||||
"notice.warning": "警告",
|
||||
"notice.details": "詳情",
|
||||
"notice.finalReadiness": "任務狀態還需要確認,已要求助手完成或說明阻塞原因。",
|
||||
"notice.emptyFinal": "沒有產生可見回覆,已要求助手重新作答。",
|
||||
"notice.executorHandoff": "助手尚未執行必要操作,已要求它使用相應工具。",
|
||||
"notice.toolBudget": "工具呼叫輪數已達上限,已要求助手總結目前進度。",
|
||||
"notice.loopGuard": "助手卡在重複受阻操作上,已要求它換一種處理方式。",
|
||||
"notice.contextLarge": "上下文正在變大,暫時保留快取,等需要時再清理。",
|
||||
"notice.contextCleanupSkipped": "本次暫未清理上下文。",
|
||||
"notice.contextCleanupPaused": "自動上下文清理已暫停:目前上下文視窗太小。",
|
||||
"notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。",
|
||||
"notice.autoPlanEnabled": "已為這個多步驟任務開啟規劃模式。",
|
||||
"notice.autoPlanRequested": "任務判斷建議先制定計畫。",
|
||||
"notice.autoPlanFallback": "規劃判斷不確定,已使用備用規則。",
|
||||
"notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。",
|
||||
"notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。",
|
||||
"notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。",
|
||||
"notice.backgroundJobFailed": "背景 {kind} 需要處理。",
|
||||
"notice.jobArtifactMigrationFailed": "任務產物遷移失敗。",
|
||||
"notice.jobTeardownTimeout": "背景任務清理逾時。",
|
||||
"notice.planModeToolSettingsIgnored": "部分規劃模式工具設定已被忽略。",
|
||||
"notice.planModeCommandSettingsIgnored": "部分規劃模式指令設定已被忽略。",
|
||||
"notice.configMigrationIncomplete": "設定遷移未完成。",
|
||||
"notice.modelMissingApiKey": "目前模型缺少 API key。",
|
||||
"notice.mcpServerFailed": "一個 MCP 服務啟動失敗。",
|
||||
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
|
||||
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
|
||||
"questionNav.label": "問題導航",
|
||||
"questionNav.progress": "問題 {current} / {total}",
|
||||
"questionNav.jump": "跳轉到問題 {n}",
|
||||
@@ -1170,6 +1264,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 +1285,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"rewind.busyConversation": "正在回滾對話…",
|
||||
"rewind.busyCode": "正在回滾程式碼…",
|
||||
"rewind.busyBoth": "正在回滾程式碼和對話…",
|
||||
"rewind.failed": "回滾失敗",
|
||||
"undoRewind.turns": "已回滾 {n} 輪對話",
|
||||
"undoRewind.filesRestored": "還原了 {n} 個檔案",
|
||||
"undoRewind.filesRemoved": "移除了 {n} 個檔案",
|
||||
@@ -1200,8 +1296,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.error": "錯誤",
|
||||
"tool.errorReceiptMismatch": "證據命令沒有匹配的成功執行記錄",
|
||||
"tool.truncated": "輸出已截斷",
|
||||
"tool.showAllLines": "顯示全部 {n} 行",
|
||||
"tool.showErrorDetails": "顯示錯誤詳情",
|
||||
"tool.hideErrorDetails": "隱藏錯誤詳情",
|
||||
"tool.lineOne": "{n} 行",
|
||||
"tool.lineOther": "{n} 行",
|
||||
"tool.matchOne": "{n} 處匹配",
|
||||
@@ -1316,6 +1416,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 +1590,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 +2074,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",
|
||||
|
||||
// 顶栏
|
||||
@@ -471,7 +472,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.send": "发送(Enter)",
|
||||
"composer.queueGuidance": "加入引导队列(Enter)",
|
||||
"composer.steer": "追加到当前任务(Enter)",
|
||||
"composer.steerPlaceholder": "输入要加入队列的引导…",
|
||||
"composer.steerPlaceholder": "正在运行——输入补充指示,回车加入队列",
|
||||
"composer.runWaitingApproval": "等待你批准 — {tool}",
|
||||
"composer.runWaitingAsk": "等待你回答",
|
||||
"composer.runAnnounceRunning": "正在运行",
|
||||
"composer.guidanceQueue": "待处理引导",
|
||||
"composer.guidanceCount": "待处理引导 {n}",
|
||||
"composer.guidanceRemaining": "还有 {n} 条",
|
||||
@@ -532,14 +536,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})",
|
||||
@@ -650,6 +677,13 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.sendRevision": "发送修改意见",
|
||||
"approval.exitPlan": "退出计划",
|
||||
"approval.toolPending": "允许工具调用",
|
||||
"approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。",
|
||||
"approval.toolLabelExitPlan": "计划确认",
|
||||
"approval.allowOnceDesc": "仅允许本次调用,下次仍会询问。",
|
||||
"approval.allowRuleSessionDesc": "本会话内匹配的调用不再询问,重启后失效。",
|
||||
"approval.allowRulePersistentDesc": "保存为持久允许规则,之后的会话也不再询问。",
|
||||
"approval.denyDesc": "拒绝本次调用,模型会收到拒绝结果并继续。",
|
||||
"approval.allowSandboxEscapeSessionDesc": "本会话后续命令改在真实环境运行,跳过沙箱。",
|
||||
"approval.details": "详情",
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒绝",
|
||||
@@ -664,6 +698,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": "需要你决定",
|
||||
@@ -702,6 +759,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "确认清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.clearRecoveryCopies": "清理副本",
|
||||
"history.confirmClearRecoveryCopies": "确认清理副本",
|
||||
"history.clearRecoveryCopiesMenu": "清理恢复副本…",
|
||||
"history.trashRecoveryCopies": "副本移入回收站",
|
||||
"history.confirmTrashRecoveryCopies": "确认移入回收站",
|
||||
"history.recoveryCopiesGroup": "恢复副本",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "确认移入回收站",
|
||||
"history.restore": "恢复",
|
||||
@@ -733,6 +796,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "无法打开会话",
|
||||
"history.failedOpenGlobal": "无法打开全局会话",
|
||||
"history.missingWorkspaceRoot": "无法打开会话:缺少工作区路径。",
|
||||
"history.selectSession": "选择一个会话以预览。",
|
||||
"history.trashSessionActions": "回收站会话操作",
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
@@ -741,14 +805,10 @@ export const zh: Record<DictKey, string> = {
|
||||
// 会话恢复
|
||||
"recovery.branch": "冲突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "打开冲突副本",
|
||||
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
|
||||
"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 +1604,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 +1725,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 +1809,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}",
|
||||
@@ -1812,6 +1887,34 @@ export const zh: Record<DictKey, string> = {
|
||||
"transcript.processedDuration": "已处理 {s}s",
|
||||
"notice.info": "提示",
|
||||
"notice.warning": "警告",
|
||||
"notice.details": "详情",
|
||||
"notice.finalReadiness": "任务状态还需要确认,已要求助手完成或说明阻塞原因。",
|
||||
"notice.emptyFinal": "没有生成可见回复,已要求助手重新作答。",
|
||||
"notice.executorHandoff": "助手尚未执行必要操作,已要求它使用相应工具。",
|
||||
"notice.toolBudget": "工具调用轮数已达上限,已要求助手总结当前进展。",
|
||||
"notice.loopGuard": "助手卡在重复受阻操作上,已要求它换一种处理方式。",
|
||||
"notice.contextLarge": "上下文正在变大,暂时保留缓存,等需要时再清理。",
|
||||
"notice.contextCleanupSkipped": "本次暂未清理上下文。",
|
||||
"notice.contextCleanupPaused": "自动上下文清理已暂停:当前上下文窗口太小。",
|
||||
"notice.compactionNoSummary": "上下文已压缩,但未生成摘要。",
|
||||
"notice.autoPlanEnabled": "已为这个多步骤任务开启规划模式。",
|
||||
"notice.autoPlanRequested": "任务判断建议先制定计划。",
|
||||
"notice.autoPlanFallback": "规划判断不确定,已使用备用规则。",
|
||||
"notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。",
|
||||
"notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。",
|
||||
"notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。",
|
||||
"notice.backgroundJobFailed": "后台 {kind} 需要处理。",
|
||||
"notice.jobArtifactMigrationFailed": "任务产物迁移失败。",
|
||||
"notice.jobTeardownTimeout": "后台任务清理超时。",
|
||||
"notice.planModeToolSettingsIgnored": "部分规划模式工具设置已被忽略。",
|
||||
"notice.planModeCommandSettingsIgnored": "部分规划模式命令设置已被忽略。",
|
||||
"notice.configMigrationIncomplete": "配置迁移未完成。",
|
||||
"notice.modelMissingApiKey": "当前模型缺少 API key。",
|
||||
"notice.mcpServerFailed": "一个 MCP 服务启动失败。",
|
||||
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
|
||||
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
|
||||
"questionNav.label": "问题导航",
|
||||
"questionNav.progress": "问题 {current} / {total}",
|
||||
"questionNav.jump": "跳转到问题 {n}",
|
||||
@@ -1827,6 +1930,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 +1951,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"rewind.busyConversation": "正在回滚对话…",
|
||||
"rewind.busyCode": "正在回滚代码…",
|
||||
"rewind.busyBoth": "正在回滚代码和对话…",
|
||||
"rewind.failed": "回滚失败",
|
||||
"undoRewind.turns": "已回滚 {n} 轮对话",
|
||||
"undoRewind.filesRestored": "还原了 {n} 个文件",
|
||||
"undoRewind.filesRemoved": "移除了 {n} 个文件",
|
||||
@@ -1857,8 +1962,12 @@ export const zh: Record<DictKey, string> = {
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.error": "错误",
|
||||
"tool.errorReceiptMismatch": "证据命令没有匹配的成功执行记录",
|
||||
"tool.truncated": "输出已截断",
|
||||
"tool.showAllLines": "显示全部 {n} 行",
|
||||
"tool.showErrorDetails": "显示错误详情",
|
||||
"tool.hideErrorDetails": "隐藏错误详情",
|
||||
"tool.readCount": "已读 {n} 个文件",
|
||||
"tool.searchCount": "搜索 {n} 个文件",
|
||||
"tool.otherReadCount": "{n} 个只读调用",
|
||||
@@ -2029,6 +2138,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",
|
||||
|
||||
+225
-174
@@ -2665,27 +2665,6 @@ a[href] {
|
||||
color: var(--ok);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.banner--recovery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: color-mix(in srgb, var(--warn) 9%, var(--bg-elev));
|
||||
color: color-mix(in srgb, var(--warn) 72%, var(--fg));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--warn) 22%, var(--border-soft));
|
||||
}
|
||||
.banner__badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--warn) 13%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 84%, var(--fg));
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
.banner--update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3310,6 +3289,20 @@ a[href] {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notice-line__details {
|
||||
margin-top: 4px;
|
||||
color: var(--fg-faint);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.notice-line__details summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.notice-line__details div {
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── ReadOnly batch (parent fold for read/search tools) ────────────── */
|
||||
.readonly-batch {
|
||||
@@ -5207,11 +5200,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;
|
||||
@@ -5272,6 +5276,46 @@ body > .mermaid-diagram--fullscreen {
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.tool__err--compact {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tool__err-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
.tool__err-toggle {
|
||||
justify-self: start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.tool__err-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.tool__err-toggle-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.tool__err-toggle-icon--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tool__err-details {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--err) 18%, transparent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
@@ -5420,13 +5464,59 @@ body > .mermaid-diagram--fullscreen {
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.composer-card--resized {
|
||||
height: var(--composer-height);
|
||||
/* --composer-height is always the user's logical height; the run strip's
|
||||
reservation lives in this calc so the live resize drag (which writes the
|
||||
raw logical height) and the React render path can never disagree. */
|
||||
height: calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.composer-card:focus-within {
|
||||
border-color: var(--fg-faint);
|
||||
}
|
||||
/* Run strip: an in-flow bar at the top of the card. For user-resized cards
|
||||
(fixed --composer-height), the JS adds the strip's reserved height so the
|
||||
meta row stays fully visible. For auto-height cards the card grows
|
||||
naturally. */
|
||||
.composer-run-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
padding: 4px 14px 0;
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
}
|
||||
.composer-card--waiting .composer-run-strip {
|
||||
/* inherited from the card border tint; no extra rule needed */
|
||||
}
|
||||
.composer-run-strip__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.composer-run-strip__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.composer-run-strip--waiting {
|
||||
color: color-mix(in srgb, var(--warn) 74%, var(--fg));
|
||||
}
|
||||
.composer-run-strip--waiting .composer-run-strip__dot {
|
||||
animation: none;
|
||||
}
|
||||
/* Attention handoff: while a prompt card is waiting on the user, the card
|
||||
drops the accent running tint and takes a calm warn edge instead. */
|
||||
.composer-card--waiting {
|
||||
border-color: color-mix(in srgb, var(--warn) 30%, var(--border));
|
||||
}
|
||||
.composer-resize-handle {
|
||||
--wails-draggable: no-drag;
|
||||
appearance: none;
|
||||
@@ -5680,6 +5770,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
.composer__btn--stop {
|
||||
margin-right: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
|
||||
color: var(--err);
|
||||
}
|
||||
.composer__btn--stop:hover,
|
||||
.composer__btn--stop:focus-visible {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
|
||||
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
|
||||
}
|
||||
.composer__btn--stop:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.composer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7197,6 +7303,16 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.approval-mode-hint {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 30%, var(--border-soft));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-soft));
|
||||
color: color-mix(in srgb, var(--warning, #f59e0b) 62%, var(--fg));
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.approval-reason {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
@@ -7378,14 +7494,14 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
max-height: 154px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
max-height: min(220px, 30vh);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-action {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
@@ -7401,6 +7517,69 @@ body > .mermaid-diagram--fullscreen {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__quick-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__quick-actions > .prompt-action {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.prompt-shelf__footnote {
|
||||
min-width: 0;
|
||||
}
|
||||
.ask-shelf__detail {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ask-shelf__detail-label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
font-weight: 620;
|
||||
}
|
||||
.ask-shelf__detail-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Tool-approval consequence preview: same quick-pick pattern as the ask
|
||||
detail row — pill buttons stay compact, the row spells out what the
|
||||
hovered/selected action will do. */
|
||||
.approval-consequence {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.approval-consequence__label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
font-weight: 620;
|
||||
}
|
||||
.approval-consequence__text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.prompt-shelf--ask .ask-shelf__custom-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -17280,21 +17459,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/* ── composer run status ──────────────────────────────────────────────────── */
|
||||
.composer-toolbar {
|
||||
--wails-draggable: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.composer-toolbar--status-only {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.composer-guidance-shelf {
|
||||
--wails-draggable: no-drag;
|
||||
display: grid;
|
||||
@@ -17451,79 +17615,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
}
|
||||
|
||||
.composer-runstatus {
|
||||
--wails-draggable: no-drag;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 5px 0 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
|
||||
}
|
||||
|
||||
.composer-runstatus__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.composer-runstatus__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.composer-runstatus__stop {
|
||||
--wails-draggable: no-drag;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
|
||||
color: var(--err);
|
||||
font: inherit;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.composer-runstatus__stop:hover,
|
||||
.composer-runstatus__stop:focus-visible {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
|
||||
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
|
||||
}
|
||||
.composer-runstatus__stop:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
color: var(--fg-faint);
|
||||
background: var(--button-bg);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
.composer-runstatus__stop:disabled:hover,
|
||||
.composer-runstatus__stop:disabled:focus-visible {
|
||||
color: var(--fg-faint);
|
||||
background: var(--button-bg);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.composer-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -22756,10 +22847,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding: 5px 10px 10px 30px;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.composer-modebar {
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent);
|
||||
@@ -26834,41 +26921,29 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app--creation .composer-toolbar,
|
||||
:root[data-theme-style] .app--creation .composer-toolbar {
|
||||
max-width: 880px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.app--creation .composer-toolbar--status-only,
|
||||
:root[data-theme-style] .app--creation .composer-toolbar--status-only {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus {
|
||||
max-width: min(100%, 340px);
|
||||
height: 28px;
|
||||
gap: 7px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 24%, transparent);
|
||||
.app--creation .composer-run-strip,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip {
|
||||
color: color-mix(in srgb, var(--accent) 88%, var(--fg-muted));
|
||||
font-size: 11.5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__dot,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__dot {
|
||||
.app--creation .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
animation: creation-runstatus-dot-breathe 1.65s ease-in-out infinite;
|
||||
animation: creation-run-strip-dot-breathe 1.65s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes creation-runstatus-dot-breathe {
|
||||
.app--creation .composer-run-strip--waiting .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__dot {
|
||||
animation: none;
|
||||
background: currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes creation-run-strip-dot-breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.42;
|
||||
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
@@ -26880,46 +26955,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app--creation .composer-runstatus__dot,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__dot {
|
||||
.app--creation .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__dot {
|
||||
animation: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__text,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__text {
|
||||
.app--creation .composer-run-strip__text,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__text {
|
||||
color: color-mix(in srgb, var(--fg-muted) 72%, var(--accent));
|
||||
font-weight: 540;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop {
|
||||
height: 22px;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
border-color: color-mix(in srgb, var(--err) 24%, transparent);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--err) 76%, var(--fg-muted));
|
||||
font-size: 11.5px;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop svg,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop svg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop:hover,
|
||||
.app--creation .composer-runstatus__stop:focus-visible,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop:hover,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--err) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 9%, transparent);
|
||||
color: color-mix(in srgb, var(--err) 90%, var(--fg));
|
||||
.app--creation .composer-run-strip--waiting .composer-run-strip__text,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__text {
|
||||
color: color-mix(in srgb, var(--warn) 64%, var(--fg-muted));
|
||||
}
|
||||
|
||||
.app--creation .composer-card,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
cmd = hook.NormalizeCommand(cmd)
|
||||
settings.Hooks[event] = append(settings.Hooks[event], hook.HookConfig{
|
||||
Match: strings.TrimSpace(h.Match),
|
||||
Command: cmd,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -23,6 +24,12 @@ func TestMain(m *testing.M) {
|
||||
os.Setenv("REASONIX_STATE_HOME", dir+"/state")
|
||||
os.Setenv("REASONIX_CACHE_HOME", dir+"/cache")
|
||||
os.Setenv("AppData", dir)
|
||||
// Neutralize the Wails runtime-event bridge for the whole test binary:
|
||||
// outside a running Wails app, runtime.EventsEmit log.Fatals on the plain
|
||||
// contexts tests use, killing the process from any emitting code path.
|
||||
// Tests that assert on runtime events install their own capture through
|
||||
// the per-instance runtimeEvents.emit hook, which takes precedence.
|
||||
runtimeEventsEmitFallback = func(context.Context, string, ...interface{}) {}
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -116,6 +117,9 @@ func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
|
||||
|
||||
sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
|
||||
view.Memories = suggestMemories(set, sessions)
|
||||
// Stable Memory v5 execution learnings join the same candidate list and
|
||||
// the same explicit-confirmation flow as history-derived suggestions.
|
||||
view.Memories = append(view.Memories, suggestCompilerMemories(workspaceRoot, set, view.Memories)...)
|
||||
view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
|
||||
return view
|
||||
}
|
||||
@@ -137,7 +141,7 @@ func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (s
|
||||
if desc == "" || body == "" {
|
||||
return "", fmt.Errorf("memory suggestion requires description and body")
|
||||
}
|
||||
name := suggestionName(in.Name, desc, "memory-candidate")
|
||||
name := acceptedSuggestionName(in.Name, desc)
|
||||
return ctrl.SaveMemory(memory.Memory{
|
||||
Name: name,
|
||||
Title: oneLine(in.Title),
|
||||
@@ -229,7 +233,7 @@ func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySugg
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := suggestionName("", statement, fmt.Sprintf("memory-candidate-%d", len(out)+1))
|
||||
name := stableSuggestionName(statement, "memory-candidate")
|
||||
title := suggestionTitle(statement, "Memory candidate")
|
||||
typ := inferMemoryType(statement)
|
||||
out = append(out, MemorySuggestion{
|
||||
@@ -504,6 +508,66 @@ func suggestionName(given, source, fallback string) string {
|
||||
return "candidate"
|
||||
}
|
||||
|
||||
// acceptedSuggestionName preserves the candidate name generated at suggestion
|
||||
// time. Re-running asciiSlug here would truncate back to 56 chars and strip
|
||||
// the uniqueness hash suffix, re-colliding long common-prefix candidates at
|
||||
// save time even though their generated Name/ID differed. A well-formed slug
|
||||
// is kept verbatim (memory.Store.Save's own slug pass cleans but never
|
||||
// truncates); anything else falls back to deriving from the description as
|
||||
// before.
|
||||
func acceptedSuggestionName(given, desc string) string {
|
||||
if isWellFormedSlug(given) {
|
||||
return given
|
||||
}
|
||||
return suggestionName("", desc, "memory-candidate")
|
||||
}
|
||||
|
||||
// isWellFormedSlug reports whether s already matches asciiSlug's output shape
|
||||
// (lowercase ASCII letters/digits separated by single dashes), possibly with a
|
||||
// hash suffix beyond asciiSlug's 56-char cap.
|
||||
func isWellFormedSlug(s string) bool {
|
||||
if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
prevDash = false
|
||||
case r == '-':
|
||||
if prevDash {
|
||||
return false
|
||||
}
|
||||
prevDash = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stableSuggestionName returns a slug that is unique per source text and stable
|
||||
// across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
|
||||
// 56 chars, so two CJK-only statements (or long English statements sharing a
|
||||
// prefix) can collide — colliding Names make Store.Save overwrite the earlier
|
||||
// memory, and colliding IDs cross-wire the frontend's accepted-state map.
|
||||
//
|
||||
// When the ASCII slug is short enough that truncation cannot have caused a
|
||||
// collision, it is returned as-is for backward compatibility with old-version
|
||||
// candidate names. The hash suffix is only appended when the slug fell back to
|
||||
// the fallback (non-ASCII source) or when the slug hit the 56-char truncation
|
||||
// boundary.
|
||||
func stableSuggestionName(source, fallback string) string {
|
||||
slug := asciiSlug(source)
|
||||
if slug != "" && len(slug) < 56 {
|
||||
return slug
|
||||
}
|
||||
base := suggestionName("", source, fallback)
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(source))
|
||||
return fmt.Sprintf("%s-%08x", base, h.Sum32())
|
||||
}
|
||||
|
||||
func asciiSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
const (
|
||||
// compilerSuggestionLimit bounds Memory v5 candidates separately from the
|
||||
// history-derived ones so a busy history cannot crowd them out.
|
||||
compilerSuggestionLimit = 3
|
||||
// compilerSuggestionMinCount requires a pattern to recur across at least
|
||||
// this many separate turns before it is stable enough to suggest.
|
||||
compilerSuggestionMinCount = 2
|
||||
)
|
||||
|
||||
// suggestCompilerMemories converts stable Memory v5 execution learnings into
|
||||
// memory candidates for the settings Memory page. Read-only: candidates are
|
||||
// only persisted through the regular AcceptMemorySuggestion confirmation flow.
|
||||
func suggestCompilerMemories(workspaceRoot string, set *memory.Set, already []MemorySuggestion) []MemorySuggestion {
|
||||
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
|
||||
if rt == nil || set == nil {
|
||||
return nil
|
||||
}
|
||||
existing := existingMemoryText(set)
|
||||
seen := map[string]bool{}
|
||||
for _, s := range already {
|
||||
seen[normalizeSuggestionKey(s.Description)] = true
|
||||
}
|
||||
var out []MemorySuggestion
|
||||
for _, p := range rt.StableNoisePatterns(compilerSuggestionMinCount, compilerSuggestionLimit*2) {
|
||||
statement := compilerPatternStatement(p.Pattern)
|
||||
key := normalizeSuggestionKey(statement)
|
||||
if key == "" || seen[key] || existingCovers(existing, key) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := compilerCandidateName(p.Pattern)
|
||||
out = append(out, MemorySuggestion{
|
||||
ID: "memory-" + name,
|
||||
Name: name,
|
||||
Title: suggestionTitle(statement, "Memory v5 pattern"),
|
||||
Description: oneLine(statement),
|
||||
Type: string(memory.TypeProject),
|
||||
Body: compilerCandidateBody(statement, p.Count),
|
||||
Reason: fmt.Sprintf("Memory v5 observed this failure pattern in %d separate turns", p.Count),
|
||||
Evidence: []string{fmt.Sprintf("memory-v5 execution traces: %s (x%d)", truncateRunes(p.Pattern, 160), p.Count)},
|
||||
})
|
||||
if len(out) >= compilerSuggestionLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compilerCandidateName always appends a hash: compiler patterns are
|
||||
// inherently homogeneous (same tool + similar error prefix) so the ASCII
|
||||
// slug alone cannot distinguish them even when it is non-empty.
|
||||
func compilerCandidateName(pattern string) string {
|
||||
base := suggestionName("", "memory-v5 "+pattern, "memory-v5-pattern")
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(pattern))
|
||||
return fmt.Sprintf("%s-%08x", base, h.Sum32())
|
||||
}
|
||||
|
||||
func compilerPatternStatement(pattern string) string {
|
||||
pattern = oneLine(pattern)
|
||||
if pattern == "" {
|
||||
return ""
|
||||
}
|
||||
return "Known repeated failure in this workspace: " + pattern + "."
|
||||
}
|
||||
|
||||
func compilerCandidateBody(statement string, count int) string {
|
||||
return statement + "\n\n" +
|
||||
fmt.Sprintf("**Why:** Memory v5 recorded this failure pattern in %d separate execution traces for this workspace.\n", count) +
|
||||
"**How to apply:** Address the known cause before retrying the same command; drop this memory once the failure stops reproducing.\n"
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
func seedCompilerFailures(t *testing.T, workspaceRoot string, turns int, errText string) {
|
||||
t.Helper()
|
||||
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
|
||||
if rt == nil {
|
||||
t.Fatal("memory compiler dir did not resolve under isolated dirs")
|
||||
}
|
||||
for i := 0; i < turns; i++ {
|
||||
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
|
||||
turn.RecordToolResults([]memorycompiler.ToolRecord{
|
||||
{Name: "bash", Error: errText},
|
||||
{Name: "bash", Error: errText},
|
||||
})
|
||||
turn.Finish(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySuggestionsIncludeCompilerCandidates(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidate MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidate = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if candidate.ID == "" {
|
||||
t.Fatalf("memories = %+v, want a memory-v5 candidate", view.Memories)
|
||||
}
|
||||
if candidate.Type != string(memory.TypeProject) {
|
||||
t.Fatalf("candidate type = %q, want project", candidate.Type)
|
||||
}
|
||||
if !strings.Contains(candidate.Description, "cannot find module") {
|
||||
t.Fatalf("candidate description missing pattern: %q", candidate.Description)
|
||||
}
|
||||
if !strings.Contains(candidate.Reason, "Memory v5") {
|
||||
t.Fatalf("candidate reason missing provenance: %q", candidate.Reason)
|
||||
}
|
||||
|
||||
path, err := app.AcceptMemorySuggestion(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("AcceptMemorySuggestion returned empty path")
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 1 || !strings.Contains(saved[0].Body, "cannot find module") {
|
||||
t.Fatalf("saved memories = %+v, want compiler candidate body", saved)
|
||||
}
|
||||
|
||||
// Once accepted, the same pattern is covered by an existing memory and
|
||||
// must not be suggested again.
|
||||
again := app.MemorySuggestions()
|
||||
for _, item := range again.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
t.Fatalf("accepted pattern suggested again: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilerCandidatesRequireStablePattern(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// A pattern seen in only one turn is not stable enough to suggest.
|
||||
seedCompilerFailures(t, cwd, 1, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
t.Fatalf("unstable pattern was suggested: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompilerCandidateNamesUniqueForSimilarPatterns covers the review
|
||||
// finding: asciiSlug drops non-ASCII runes and truncates to 56 chars, so
|
||||
// patterns differing only in CJK text (or sharing a long English prefix) used
|
||||
// to collide on Name/ID — colliding IDs cross-wire the frontend's accepted
|
||||
// state and colliding Names make Store.Save overwrite the earlier memory.
|
||||
func TestCompilerCandidateNamesUniqueForSimilarPatterns(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// Same tool, errors differ only in CJK text: identical after asciiSlug.
|
||||
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块甲")
|
||||
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块乙")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidates []MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %+v, want both CJK-only-diff patterns", candidates)
|
||||
}
|
||||
if candidates[0].Name == candidates[1].Name || candidates[0].ID == candidates[1].ID {
|
||||
t.Fatalf("similar patterns collided on Name/ID: %q vs %q", candidates[0].Name, candidates[1].Name)
|
||||
}
|
||||
|
||||
// Accepting both must persist two distinct memories, not overwrite one.
|
||||
for _, c := range candidates {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("saved memories = %d (%+v), want 2 distinct files", len(saved), saved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompilerCandidateNamesStableAcrossRefreshes: the hash suffix must be
|
||||
// derived from the pattern, not from ordering or randomness, so a refresh
|
||||
// keeps the same ID and the frontend's accepted-state map stays valid.
|
||||
func TestCompilerCandidateNamesStableAcrossRefreshes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
first := app.MemorySuggestions()
|
||||
second := app.MemorySuggestions()
|
||||
firstIDs := compilerCandidateIDs(first.Memories)
|
||||
secondIDs := compilerCandidateIDs(second.Memories)
|
||||
if len(firstIDs) == 0 || strings.Join(firstIDs, ",") != strings.Join(secondIDs, ",") {
|
||||
t.Fatalf("candidate IDs changed across refreshes: %v vs %v", firstIDs, secondIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func compilerCandidateIDs(memories []MemorySuggestion) []string {
|
||||
var out []string
|
||||
for _, item := range memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
out = append(out, item.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestCompilerCandidateLongPrefixNamesPersistDistinctly reproduces the review
|
||||
// finding: two patterns sharing a >56-char ASCII prefix get distinct generated
|
||||
// Name/ID (hash suffix), but the accept path used to re-run asciiSlug, which
|
||||
// truncates to 56 chars and strips the hash — so both saved to one file.
|
||||
func TestCompilerCandidateLongPrefixNamesPersistDistinctly(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
prefix := "cannot find module providing package example dot com slash very long prefix "
|
||||
seedCompilerFailures(t, cwd, 2, prefix+"alpha")
|
||||
seedCompilerFailures(t, cwd, 2, prefix+"beta")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidates []MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %+v, want both long-prefix patterns", candidates)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("saved memories = %d, want 2 distinct files (accept path truncated the hash suffix)", len(saved))
|
||||
}
|
||||
if saved[0].Name == saved[1].Name {
|
||||
t.Fatalf("saved names collided: %q", saved[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -210,3 +210,129 @@ func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider
|
||||
t.Fatalf("save session %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
|
||||
// asciiSlug is short (<56 chars) must produce the same Name as old code
|
||||
// (plain slug, no hash suffix), so an already-accepted memory under the old
|
||||
// Name is not duplicated after upgrade.
|
||||
func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
writeSuggestionSession(t, sessionDir, "en.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
if len(view.Memories) == 0 {
|
||||
t.Fatalf("no candidates")
|
||||
}
|
||||
// Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
|
||||
oldName := asciiSlug("Always prefer English for code comments.")
|
||||
if view.Memories[0].Name != oldName {
|
||||
t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
|
||||
// differ in content but produce the same empty asciiSlug must still get
|
||||
// distinct Name/ID. Without the hash suffix they would both fall back to
|
||||
// "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
|
||||
// wouldn't survive refresh, and Store.Save overwrites by name.
|
||||
func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// Two pure-CJK "always" statements that pass extractMemoryStatement but
|
||||
// share the exact same empty asciiSlug.
|
||||
writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
if len(view.Memories) < 2 {
|
||||
t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
ids := map[string]bool{}
|
||||
for _, m := range view.Memories {
|
||||
if names[m.Name] {
|
||||
t.Fatalf("duplicate Name %q among history candidates", m.Name)
|
||||
}
|
||||
if ids[m.ID] {
|
||||
t.Fatalf("duplicate ID %q among history candidates", m.ID)
|
||||
}
|
||||
names[m.Name] = true
|
||||
ids[m.ID] = true
|
||||
}
|
||||
|
||||
// Accept both → two distinct persisted memories.
|
||||
for _, c := range view.Memories {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != len(view.Memories) {
|
||||
t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
|
||||
// be derived from the statement, not from iteration order or random state, so
|
||||
// a refresh keeps the same ID and the frontend's accepted-state map stays valid.
|
||||
func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
writeSuggestionSession(t, sessionDir, "pref.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
first := app.MemorySuggestions()
|
||||
second := app.MemorySuggestions()
|
||||
if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
|
||||
t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
|
||||
}
|
||||
for i := range first.Memories {
|
||||
if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
|
||||
t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
|
||||
i, first.Memories[i].Name, first.Memories[i].ID,
|
||||
second.Memories[i].Name, second.Memories[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func (m *metricsAggregator) observe(e event.Event) {
|
||||
case event.MemoryCompilerStatsEvent:
|
||||
m.observeMemoryCompilerStats(e.MemoryCompiler)
|
||||
case event.Notice:
|
||||
if strings.HasPrefix(e.Text, "empty final answer blocked") {
|
||||
if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
|
||||
m.inc("empty_final", "total")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestObserveClassifiesEvents(t *testing.T) {
|
||||
StrategyCount: 3,
|
||||
LearningCount: 6,
|
||||
}},
|
||||
{Kind: event.Notice, Text: "empty final answer blocked: model returned no visible answer text; retrying"},
|
||||
{Kind: event.Notice, Text: "No visible answer was produced; asking the assistant to respond again.", Detail: "empty final answer blocked: model returned no visible answer text; retrying"},
|
||||
{Kind: event.TurnDone, Err: errors.New("deepseek-flash: status 429: rate limited")},
|
||||
{Kind: event.TurnDone},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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) {
|
||||
key := projectRootKey(dir)
|
||||
if dir == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = 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,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
|
||||
// prompt-chime dedupe depends on: every in-place controller replacement must
|
||||
// announce itself. Model, effort, and token-mode switches rebuild the tab's
|
||||
// controller WITHOUT an agent:ready — the rebuilt controller restarts its
|
||||
// approval/ask id counter at "1", so without runtime:rebuilt the frontend's
|
||||
// id-keyed chime dedupe mutes the first prompt after a switch.
|
||||
func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(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: "deepseek-v4-pro", 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)
|
||||
}
|
||||
sess := agent.NewSession("sys")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
path := filepath.Join(dir, "rebuild-events.jsonl")
|
||||
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
// emitReady calls the Wails runtime directly; the ready hook keeps the
|
||||
// workspace-reconcile path (which SetEffortForTab can take) off the real
|
||||
// event bridge, which log.Fatals on a plain Background context.
|
||||
app.readyHook = func() {}
|
||||
|
||||
var mu sync.Mutex
|
||||
var rebuilt []string
|
||||
// The App-level queue must stay silent: ordering against the tab's agent
|
||||
// events only holds when the notice rides the tab sink's own queue, so a
|
||||
// notice showing up here means the routing regressed to the fallback.
|
||||
app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
|
||||
if name == "runtime:rebuilt" {
|
||||
mu.Lock()
|
||||
rebuilt = append(rebuilt, "VIA-APP-QUEUE")
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
sinkEmit := func(_ context.Context, name string, payload ...interface{}) {
|
||||
if name != "runtime:rebuilt" {
|
||||
return
|
||||
}
|
||||
tabID := ""
|
||||
if len(payload) > 0 {
|
||||
tabID, _ = payload[0].(string)
|
||||
}
|
||||
mu.Lock()
|
||||
rebuilt = append(rebuilt, tabID)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_rebuild_events",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
Ctrl: ctrl,
|
||||
sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink.runtimeEvents.emit = sinkEmit
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
waitCount := func(want int, step string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(rebuilt)
|
||||
mu.Unlock()
|
||||
if n >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
|
||||
}
|
||||
|
||||
if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
|
||||
t.Fatalf("SetModelForTab: %v", err)
|
||||
}
|
||||
waitCount(1, "model switch")
|
||||
|
||||
if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
waitCount(2, "effort switch")
|
||||
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
waitCount(3, "token-mode switch")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, id := range rebuilt {
|
||||
if id == "VIA-APP-QUEUE" {
|
||||
t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
|
||||
}
|
||||
if id != tab.ID {
|
||||
t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1137,6 +1137,30 @@ func TestSaveHooksSettingsPreservesUnknownSettingsKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveHooksSettingsNormalizesQuotedNodeEvalHookCommand(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
|
||||
bad := `node -e "\"` + script + `\""`
|
||||
want := hook.NormalizeCommand(bad)
|
||||
if want == bad {
|
||||
t.Fatal("test command did not normalize")
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SaveHooksSettings("global", []HookConfigView{{
|
||||
Event: string(hook.PreToolUse),
|
||||
Match: "bash",
|
||||
Command: bad,
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveHooksSettings: %v", err)
|
||||
}
|
||||
|
||||
view := app.HooksSettings("global")
|
||||
if len(view.Hooks) != 1 || view.Hooks[0].Command != want {
|
||||
t.Fatalf("HooksSettings = %+v, want normalized command %q", view.Hooks, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectHooksSettingsUseActiveWorkspaceRootAndTrust(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
project := t.TempDir()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+836
-204
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// Planner notices are persisted through plannerDisplay into session history;
|
||||
// dropping Detail here would make the expandable diagnostics disappear after
|
||||
// a reload even though the live transcript showed them.
|
||||
func TestRecordPlannerDisplayEventKeepsNoticeDetail(t *testing.T) {
|
||||
tab := &WorkspaceTab{}
|
||||
tab.recordPlannerDisplayEvent(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Source: event.UsageSourcePlanner,
|
||||
Text: "An MCP server failed to start.",
|
||||
Detail: `mcp server "github" failed to start: command not found`,
|
||||
})
|
||||
if len(tab.plannerDisplay) != 1 {
|
||||
t.Fatalf("plannerDisplay len = %d, want 1", len(tab.plannerDisplay))
|
||||
}
|
||||
got := tab.plannerDisplay[0]
|
||||
if got.Role != "notice" || got.Level != "warn" {
|
||||
t.Fatalf("unexpected notice message: %+v", got)
|
||||
}
|
||||
if got.Content != "An MCP server failed to start." {
|
||||
t.Fatalf("Content = %q", got.Content)
|
||||
}
|
||||
if got.Detail != `mcp server "github" failed to start: command not found` {
|
||||
t.Fatalf("Detail = %q, want diagnostic detail preserved", got.Detail)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,122 @@ func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRecoverySessionsDoNotMigrateIntoTopics(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
normal := writeLegacySession(t, dir, "normal.jsonl", "normal imported prompt", time.Now().Add(-2*time.Hour))
|
||||
recovery := writeLegacySession(t, dir, "normal-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
||||
t.Fatalf("project tree = %#v, want global folder", nodes)
|
||||
}
|
||||
if got := len(nodes[0].Children); got != 1 {
|
||||
t.Fatalf("global migrated topics = %d, want only the normal session: %#v", got, nodes[0].Children)
|
||||
}
|
||||
if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(normal); got != want {
|
||||
t.Fatalf("migrated topic = %q, want normal session topic %q", got, want)
|
||||
}
|
||||
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil {
|
||||
t.Fatalf("load recovery meta: %v", err)
|
||||
} else if ok && strings.TrimSpace(meta.TopicID) != "" {
|
||||
t.Fatalf("legacy recovery branch was migrated into topic %q", meta.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: recovery, Label: "test"})
|
||||
defer ctrl.Close()
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
|
||||
sessions := app.ListSessions()
|
||||
for _, session := range sessions {
|
||||
if filepath.Clean(session.Path) != filepath.Clean(recovery) {
|
||||
continue
|
||||
}
|
||||
if !session.Recovered {
|
||||
t.Fatalf("history session recovered flag = false, want true: %+v", session)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("history sessions = %#v, want recovery session %q", sessions, recovery)
|
||||
}
|
||||
|
||||
func TestTrashMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
|
||||
if err := deleteSessionFile(dir, recovery); err != nil {
|
||||
t.Fatalf("delete recovery session: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(recovery), filepath.Base(recovery))
|
||||
|
||||
sessions := NewApp().ListTrashedSessions()
|
||||
if len(sessions) != 1 || filepath.Clean(sessions[0].Path) != filepath.Clean(trashPath) {
|
||||
t.Fatalf("trashed sessions = %#v, want %q", sessions, trashPath)
|
||||
}
|
||||
if !sessions[0].Recovered {
|
||||
t.Fatalf("trashed recovery session recovered flag = false, want true: %+v", sessions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTreeHidesAlreadyMigratedLegacyRecoveryTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
|
||||
topicID := legacySessionTopicID(recovery)
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
|
||||
ID: agent.BranchID(recovery),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: "恢复分支",
|
||||
Turns: 1,
|
||||
Preview: "legacy recovery prompt",
|
||||
}); err != nil {
|
||||
t.Fatalf("save migrated recovery meta: %v", err)
|
||||
}
|
||||
if err := setTopicTitle("", topicID, "恢复分支"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicsInProjectsFile("", []string{topicID}, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.TopicID == topicID {
|
||||
t.Fatalf("already-migrated recovery topic should be hidden, got node %+v", node)
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
}
|
||||
|
||||
func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
@@ -491,6 +607,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 +2216,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)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/checkpoint"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
@@ -251,6 +252,264 @@ func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
// A textually different spelling of the same folder. filepath.Join would
|
||||
// clean the dot segment away and hand back the identical string, so build
|
||||
// the spelling by hand.
|
||||
equivalentRoot := projectRoot + string(filepath.Separator) + "."
|
||||
|
||||
f := normalizeProjectsFile(desktopProjectFile{
|
||||
Projects: []desktopProject{
|
||||
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
|
||||
{Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}},
|
||||
},
|
||||
PinnedProjects: []string{equivalentRoot},
|
||||
SidebarOrder: []string{equivalentRoot, projectRoot},
|
||||
})
|
||||
|
||||
if len(f.Projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want one merged project", f.Projects)
|
||||
}
|
||||
if f.Projects[0].Root != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
|
||||
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
|
||||
}
|
||||
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
|
||||
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
|
||||
}
|
||||
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root {
|
||||
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root)
|
||||
}
|
||||
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root {
|
||||
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := removeProject(projectRoot); err != nil {
|
||||
t.Fatalf("remove project: %v", err)
|
||||
}
|
||||
if got := loadProjectsFile().Projects; len(got) != 0 {
|
||||
t.Fatalf("projects after remove = %+v, want none", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
installNoopRuntimeEvents(app)
|
||||
if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil {
|
||||
t.Fatalf("switch workspace: %v", err)
|
||||
} else if got != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
}
|
||||
|
||||
// flipPathASCIICase returns the path with the case of every ASCII letter
|
||||
// swapped — on Windows an equivalent spelling of the same folder that
|
||||
// normalizeProjectRoot cannot fold away.
|
||||
func flipPathASCIICase(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
flipped := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return r - 'a' + 'A'
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r - 'A' + 'a'
|
||||
}
|
||||
return r
|
||||
}, path)
|
||||
if flipped == path {
|
||||
t.Skipf("path %q contains no ASCII letters to flip", path)
|
||||
}
|
||||
return flipped
|
||||
}
|
||||
|
||||
func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
f := normalizeProjectsFile(desktopProjectFile{
|
||||
Projects: []desktopProject{
|
||||
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
|
||||
{Root: flipped, Color: "blue", Topics: []string{"topic_b"}},
|
||||
},
|
||||
PinnedProjects: []string{flipped},
|
||||
SidebarOrder: []string{flipped, projectRoot},
|
||||
})
|
||||
|
||||
if len(f.Projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects)
|
||||
}
|
||||
canonical := f.Projects[0].Root
|
||||
if canonical != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
|
||||
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
|
||||
}
|
||||
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
|
||||
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
|
||||
}
|
||||
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical {
|
||||
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical)
|
||||
}
|
||||
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical {
|
||||
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := addProject(flipped, ""); err != nil {
|
||||
t.Fatalf("re-add project under flipped case: %v", err)
|
||||
}
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects)
|
||||
}
|
||||
if projects[0].Root != normalizeProjectRoot(flipped) {
|
||||
t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped))
|
||||
}
|
||||
if projects[0].Title != "Project" {
|
||||
t.Fatalf("title = %q, want preserved across re-add", projects[0].Title)
|
||||
}
|
||||
|
||||
if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
projects = loadProjectsFile().Projects
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects)
|
||||
}
|
||||
if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" {
|
||||
t.Fatalf("topics = %v, want [topic_a] on the merged entry", got)
|
||||
}
|
||||
|
||||
if err := removeProject(projectRoot); err != nil {
|
||||
t.Fatalf("remove project via original spelling: %v", err)
|
||||
}
|
||||
if got := loadProjectsFile().Projects; len(got) != 0 {
|
||||
t.Fatalf("projects after remove = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
installNoopRuntimeEvents(app)
|
||||
app.tabs["tab_case"] = &WorkspaceTab{
|
||||
ID: "tab_case",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: normalizeProjectRoot(projectRoot),
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabOrder = []string{"tab_case"}
|
||||
|
||||
// Re-registering under the flipped spelling self-heals the registry root;
|
||||
// open tabs must follow so the frontend keeps comparing one string form.
|
||||
app.registerProjectRoot(flipped)
|
||||
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) {
|
||||
t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped))
|
||||
}
|
||||
if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root {
|
||||
t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
// Register under original spelling.
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
|
||||
// Write a session file with the original root spelling in its meta.
|
||||
sessionDir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(sessionDir, "topic-case.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session file: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
|
||||
TopicID: "topic_case",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
// Re-add under the flipped-case spelling — simulates Windows Explorer
|
||||
// or a different shell returning the same folder with different case.
|
||||
app := NewApp()
|
||||
installNoopRuntimeEvents(app)
|
||||
app.registerProjectRoot(flipped)
|
||||
|
||||
// findTopicSessionForTarget must match the session whose meta carries
|
||||
// the original case spelling against the registry's new (flipped) root.
|
||||
path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case")
|
||||
if path == "" {
|
||||
t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match")
|
||||
}
|
||||
if path != sessionPath {
|
||||
t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
missing := filepath.Join(parent, "deleted", "project")
|
||||
|
||||
@@ -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)。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。
|
||||
|
||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user