mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f4ef59f2ff | ||
|
|
7bb7290c2f | ||
|
|
07794a0533 | ||
|
|
bc18992293 | ||
|
|
b942018011 | ||
|
|
080e0d1182 | ||
|
|
f9b2c3abe8 | ||
|
|
2c9f2c7788 | ||
|
|
89c3958040 | ||
|
|
f0221714c9 | ||
|
|
5311e0c9eb | ||
|
|
6159661f92 | ||
|
|
963b43c625 | ||
|
|
e5494594c2 | ||
|
|
56ce1d0a18 | ||
|
|
0f7f43e744 | ||
|
|
564afe1981 | ||
|
|
666a947bce | ||
|
|
06bbd210ee | ||
|
|
118e7f9c3b | ||
|
|
e509626a00 | ||
|
|
f5fb4c5b75 | ||
|
|
ebdfe03167 | ||
|
|
e4272b885b | ||
|
|
155f08893c | ||
|
|
4cf882e80d | ||
|
|
761627c655 | ||
|
|
32fe51e1cb | ||
|
|
e8ebd833b1 | ||
|
|
a43bf21e4d | ||
|
|
efa942eb91 | ||
|
|
d1637e2415 | ||
|
|
f6a2074130 | ||
|
|
451c9f95cc | ||
|
|
f3223bcfbd | ||
|
|
90e7b1d531 | ||
|
|
1d3320e876 | ||
|
|
2d2841fdbd | ||
|
|
a2eebf8f41 | ||
|
|
2c6afe20d4 | ||
|
|
3426dea1f7 | ||
|
|
c541413fde | ||
|
|
09db5dc7ff | ||
|
|
f87c736a96 | ||
|
|
ab3c108b30 | ||
|
|
c7c127c310 | ||
|
|
885c7886ef | ||
|
|
377c3c3f90 | ||
|
|
96ff4b6503 | ||
|
|
a987c902bf | ||
|
|
2ed83f545d | ||
|
|
7fdb4fbc8a | ||
|
|
d75036fd3b | ||
|
|
807b98a077 | ||
|
|
bd7d0fcd67 | ||
|
|
1e4f38271c | ||
|
|
223f34678f | ||
|
|
393f8508ca | ||
|
|
565216c28c | ||
|
|
82e7ba1068 | ||
|
|
24946f31c7 | ||
|
|
dfa0928859 | ||
|
|
11fc7d1b33 | ||
|
|
0a1839adc9 | ||
|
|
c3c3cac1f9 | ||
|
|
9c06b33ffa | ||
|
|
39270df554 | ||
|
|
fd9f8027f4 | ||
|
|
d5949f79bb | ||
|
|
250b9f6f91 | ||
|
|
c454608baf | ||
|
|
b95698d03c | ||
|
|
65118e2d9b | ||
|
|
62cbf89624 | ||
|
|
0bffc871da | ||
|
|
65be8e9d0c | ||
|
|
c0867f60a5 | ||
|
|
020a1f1757 | ||
|
|
02d8bf531f | ||
|
|
76dc8a48bc | ||
|
|
01c4e63e58 | ||
|
|
6abb384aac | ||
|
|
c146456d8a |
No files matched your search
@@ -48,6 +48,7 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os != 'Windows'
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -55,6 +56,19 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./...
|
||||
|
||||
race:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -269,7 +269,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
|
||||
# Repository-wide "latest" is reserved for the CLI line (see header);
|
||||
# without this a desktop release published after the CLI one steals it.
|
||||
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes --latest=false)
|
||||
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
|
||||
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
|
||||
|
||||
@@ -367,6 +369,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 +380,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 +393,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 +403,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
|
||||
@@ -53,6 +53,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 +64,58 @@ 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 — so verify it 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
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
|
||||
.PHONY: build vet fmt test hooks cross clean
|
||||
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -17,6 +17,15 @@ fmt:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
|
||||
@@ -137,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
|
||||
|
||||
+1
-6
@@ -33,12 +33,6 @@
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
|
||||
@@ -126,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 重写版。
|
||||
|
||||
@@ -62,6 +62,24 @@ In a plain browser the native bindings are absent, so `bridge.ts` falls back to
|
||||
exact same event contract — so layout, streaming, markdown, tool cards, and the
|
||||
diff seam can all be built without rebuilding Go.
|
||||
|
||||
## Test
|
||||
|
||||
The desktop package is a nested Go module, so parent `go test ./...` does not run
|
||||
it. Use the full lane before merging desktop changes, and the short lane for fast
|
||||
local feedback:
|
||||
|
||||
```sh
|
||||
make desktop-test # cd desktop && go test .
|
||||
make desktop-test-short # skips slow desktop integration/e2e checks
|
||||
```
|
||||
|
||||
To find the next bottleneck, rank individual test cases from the JSON stream:
|
||||
|
||||
```sh
|
||||
make desktop-test-times
|
||||
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
```
|
||||
|
||||
### Frontend UI review checklist
|
||||
|
||||
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
|
||||
|
||||
+1040
-379
File diff suppressed because it is too large.
Load diff
@@ -248,8 +248,8 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
if tab.TopicID == "" || tab.TopicID == originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
|
||||
if tab.TopicID != originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
@@ -263,22 +263,22 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
found := false
|
||||
foundOriginal := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.TopicID == tab.TopicID {
|
||||
found = true
|
||||
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
if node.Recovered {
|
||||
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
|
||||
}
|
||||
if node.TopicID == originalTopic {
|
||||
foundOriginal = true
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !found {
|
||||
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
|
||||
if !foundOriginal {
|
||||
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,60 @@ func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
before := time.Now().UnixMilli()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
after := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
|
||||
createdAt := loadTopicCreatedAt("", meta.TopicID)
|
||||
if createdAt < before || createdAt > after {
|
||||
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "topic_20260704-104018_deadbeef"
|
||||
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
if got := loadTopicCreatedAt("", topicID); got != 0 {
|
||||
t.Fatalf("createdAt before reuse = %d, want 0", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topicID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
|
||||
}
|
||||
|
||||
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
|
||||
if got := loadTopicCreatedAt("", topicID); got != expected {
|
||||
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
||||
|
||||
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
@@ -588,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) {
|
||||
|
||||
+1576
-8
File diff suppressed because it is too large.
Load diff
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -138,12 +138,19 @@ func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStar
|
||||
|
||||
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
|
||||
installID = strings.TrimSpace(installID)
|
||||
// Copy the session under a.mu: overlapping polls of the same install can
|
||||
// race the locked PollDomain upgrade below with unlocked field reads.
|
||||
a.mu.RLock()
|
||||
session := a.botInstalls[installID]
|
||||
sessionPtr := a.botInstalls[installID]
|
||||
var sessionCopy botInstallSession
|
||||
if sessionPtr != nil {
|
||||
sessionCopy = *sessionPtr
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if session == nil {
|
||||
if sessionPtr == nil {
|
||||
return BotInstallPollResult{Error: "install session not found"}, nil
|
||||
}
|
||||
session := &sessionCopy
|
||||
if time.Now().After(session.ExpireAt) {
|
||||
a.deleteBotInstall(installID)
|
||||
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
|
||||
|
||||
+50
-23
@@ -23,10 +23,15 @@ type BotRuntimeStatusView struct {
|
||||
}
|
||||
|
||||
type desktopBotRuntime struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
// lifecycleMu serializes start/stop transitions so two apply/stop calls
|
||||
// can't race a gateway into existence. The slow work (gw.Stop teardown,
|
||||
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
|
||||
// never block on a restart.
|
||||
lifecycleMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
}
|
||||
|
||||
func newDesktopBotRuntime() *desktopBotRuntime {
|
||||
@@ -65,8 +70,11 @@ func (a *App) refreshBotRuntimeAsync() {
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntime() {
|
||||
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
|
||||
// App with no bot runtime, which must not lazily create one from a
|
||||
// background goroutine (that would race a concurrent refresh).
|
||||
if a.botRuntime == nil {
|
||||
a.botRuntime = newDesktopBotRuntime()
|
||||
return
|
||||
}
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
@@ -77,7 +85,11 @@ func (a *App) refreshBotRuntime() {
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
// Read-only load feeding the bot runtime and connection diagnostics. It
|
||||
// must load credentials: the runtime resolves app secrets and control
|
||||
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
|
||||
// credential-free view load would leave unset on a fresh process.
|
||||
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,11 +117,11 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
parent = context.Background()
|
||||
}
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopLocked()
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
if !plan.Start {
|
||||
r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message}
|
||||
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -176,14 +188,14 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
cancel()
|
||||
r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"}
|
||||
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
|
||||
return nil
|
||||
}
|
||||
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
cancel()
|
||||
gw.Stop()
|
||||
r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()}
|
||||
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
|
||||
return err
|
||||
}
|
||||
runningConnections := gw.AdapterCount()
|
||||
@@ -194,6 +206,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
status = "degraded"
|
||||
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cancel = cancel
|
||||
r.gw = gw
|
||||
r.status = BotRuntimeStatusView{
|
||||
@@ -203,6 +216,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
Connections: runningConnections,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,23 +288,36 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stop(status, message string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopLocked()
|
||||
r.status = BotRuntimeStatusView{Status: status, Message: message}
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stopLocked() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
r.cancel = nil
|
||||
// stopCurrent detaches the running gateway under r.mu, then tears it down
|
||||
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
|
||||
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
|
||||
func (r *desktopBotRuntime) stopCurrent() {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
gw := r.gw
|
||||
r.cancel = nil
|
||||
r.gw = nil
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if r.gw != nil {
|
||||
r.gw.Stop()
|
||||
r.gw = nil
|
||||
if gw != nil {
|
||||
gw.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
|
||||
r.mu.Lock()
|
||||
r.status = status
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -302,7 +302,8 @@ status = "connected"
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
@@ -310,6 +311,17 @@ status = "connected"
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
@@ -351,7 +363,8 @@ status = "connected"
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
@@ -359,13 +372,21 @@ status = "connected"
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() == "dark" {
|
||||
t.Fatal("legacy project desktop theme should not be persisted during bot-only migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
|
||||
@@ -36,6 +36,7 @@ Unicode true
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
@@ -76,6 +77,8 @@ ManifestDPIAware true
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
|
||||
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
|
||||
!define REASONIX_UNLOCK_RETRIES 60
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
|
||||
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
@@ -130,14 +133,54 @@ fallback:
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.waitForExecutableUnlock
|
||||
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
|
||||
StrCpy $0 0
|
||||
|
||||
retry:
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
Goto done
|
||||
|
||||
locked:
|
||||
IntOp $0 $0 + 1
|
||||
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
|
||||
Sleep 1000
|
||||
Goto retry
|
||||
|
||||
failed:
|
||||
IfSilent silent interactive
|
||||
|
||||
interactive:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
|
||||
Goto retry
|
||||
|
||||
silent:
|
||||
SetErrorLevel 1618
|
||||
|
||||
abort:
|
||||
Abort "Reasonix is still running. Close Reasonix and run the installer again."
|
||||
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
Call reasonix.waitForExecutableUnlock
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
!else
|
||||
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
|
||||
!endif
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
@@ -155,6 +198,7 @@ Section "uninstall"
|
||||
|
||||
; Precision uninstall: delete main application files
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
line := fmt.Sprintf(`"%s" /S`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installDir, relaunch string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := newLogger()
|
||||
if installer == "" {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err := runInstaller(installer, installDir); err != nil {
|
||||
logger.Printf("run installer: %v", err)
|
||||
return 1
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunch(relaunch, installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
dir = filepath.Join(dir, "Reasonix", "updates")
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||
return log.New(f, "", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
return log.New(os.Stderr, "", log.LstdFlags)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
if err == windows.ERROR_INVALID_PARAMETER {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
waitMS := uint32(timeout / time.Millisecond)
|
||||
result, err := windows.WaitForSingleObject(h, waitMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
default:
|
||||
return fmt.Errorf("unexpected wait result %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -46,15 +46,32 @@ func writePendingCrash(site string, r any, stack []byte) {
|
||||
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
|
||||
report.TopFrame = topFrameFromStack(report.Stack)
|
||||
report.Message = msg
|
||||
_ = writePendingReport(report, true)
|
||||
}
|
||||
|
||||
func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
path := pendingCrashPath()
|
||||
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(path, body, 0o644)
|
||||
if overwrite {
|
||||
return os.WriteFile(path, body, 0o644) == nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
n, err := f.Write(body)
|
||||
if err != nil || n != len(body) {
|
||||
_ = os.Remove(path)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) goSafe(site string, fn func()) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
@@ -67,6 +68,66 @@ func TestWritePendingCrashCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
writePendingCrash("panic", "boom", []byte("stack"))
|
||||
before, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected initial pending crash")
|
||||
}
|
||||
|
||||
hang := baseCrashReport("performance")
|
||||
hang.Source = "native.watchdog"
|
||||
hang.Label = "mac.main_thread.hang"
|
||||
hang.Message = "hang"
|
||||
if writePendingReport(hang, false) {
|
||||
t.Fatal("writePendingReport overwrite=false should not replace existing report")
|
||||
}
|
||||
after, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending crash after skipped write")
|
||||
}
|
||||
if after.Label != before.Label || after.Message != before.Message {
|
||||
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
const writers = 32
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
var done sync.WaitGroup
|
||||
var successes atomic.Int32
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
ready.Add(1)
|
||||
done.Add(1)
|
||||
go func() {
|
||||
defer done.Done()
|
||||
report := baseCrashReport("performance")
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.Message = strings.Repeat("hang", 1024)
|
||||
ready.Done()
|
||||
<-start
|
||||
if writePendingReport(report, false) {
|
||||
successes.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
done.Wait()
|
||||
|
||||
if got := successes.Load(); got != 1 {
|
||||
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
|
||||
}
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Fatal("expected one pending report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
// deferredRebuildRetryInterval is how often the retry loop probes a held
|
||||
// session lease. Package-level so tests can shorten it.
|
||||
var deferredRebuildRetryInterval = 2 * time.Second
|
||||
|
||||
// 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.
|
||||
type deferredRebuildState struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]string // tab ID -> setting label for notices
|
||||
enabled bool
|
||||
running bool
|
||||
stopped bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// enableDeferredRebuildRetry arms the retry loop; called from startup.
|
||||
func (a *App) enableDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.enabled = true
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
|
||||
// has work. Callers must hold d.mu.
|
||||
func (a *App) startDeferredRebuildLoopLocked() {
|
||||
d := &a.deferredRebuild
|
||||
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
|
||||
return
|
||||
}
|
||||
d.running = true
|
||||
if d.stop == nil {
|
||||
d.stop = make(chan struct{})
|
||||
}
|
||||
go a.deferredRebuildLoop(d.stop)
|
||||
}
|
||||
|
||||
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
|
||||
// setting and starts the retry loop if it is not running yet. Repeated calls
|
||||
// for the same tab collapse into one retry carrying the latest label.
|
||||
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
if tabID == "" {
|
||||
return
|
||||
}
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
if d.pending == nil {
|
||||
d.pending = map[string]string{}
|
||||
}
|
||||
d.pending[tabID] = setting
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
func (a *App) clearDeferredRebuild(tabID string) {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
delete(d.pending, tabID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildPending(tabID string) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
_, ok := d.pending[tabID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
|
||||
// and by tests.
|
||||
func (a *App) stopDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
if d.stop != nil {
|
||||
close(d.stop)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(deferredRebuildRetryInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
d.running = false
|
||||
d.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
if a.deferredRebuildTickDone() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deferredRebuildTickDone runs one retry pass and reports true when the loop
|
||||
// should exit because nothing is pending anymore.
|
||||
func (a *App) deferredRebuildTickDone() bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
if d.stopped || len(d.pending) == 0 {
|
||||
d.running = false
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
pending := make(map[string]string, len(d.pending))
|
||||
for id, setting := range d.pending {
|
||||
pending[id] = setting
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
for tabID, setting := range pending {
|
||||
a.retryDeferredRebuild(tabID, setting)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil || tab.ID != tabID {
|
||||
// The tab is gone; nothing left to refresh.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
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.
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
// rebuildSettingLocked refreshes the active tab only. Wait until the user
|
||||
// is back on this tab so we refresh the runtime the pending setting was
|
||||
// meant for, not whichever tab happens to be focused.
|
||||
if a.activeTab() != tab {
|
||||
return
|
||||
}
|
||||
ctrl := a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
// Mid-(re)build on another path (provider retarget, workspace repair);
|
||||
// racing a second build+swap against it is what this loop must avoid.
|
||||
return
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(ctrl) {
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildSettingLocked(setting)
|
||||
if err == nil {
|
||||
// rebuildSettingLocked already cleared the pending entry for the tab it
|
||||
// refreshed; just announce it.
|
||||
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return // grabbed back before we could rebuild; keep waiting
|
||||
}
|
||||
var busy *rebuildBusyError
|
||||
if errors.As(err, &busy) {
|
||||
return // a turn started meanwhile; retry once it finishes
|
||||
}
|
||||
// Anything else will not resolve by waiting; give up loudly instead of
|
||||
// retrying forever.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
|
||||
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
|
||||
}
|
||||
|
||||
// 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
|
||||
// reconciled path the rebuild will use; a stale answer either re-defers on the
|
||||
// next tick or lets the rebuild fail back into the pending set, so a mismatch
|
||||
// only delays the retry.
|
||||
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
|
||||
a.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
path := strings.TrimSpace(tab.SessionPath)
|
||||
a.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return true // nothing to probe; let the rebuild decide
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
|
||||
if err != nil {
|
||||
var leaseErr *agent.SessionLeaseError
|
||||
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
|
||||
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
|
||||
// This process (usually this very tab) holds the probed path;
|
||||
// the blocking lease is some other path. Attempt the rebuild
|
||||
// and let its own lease checks decide.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !errors.Is(err, agent.ErrSessionLeaseHeld)
|
||||
}
|
||||
lease.Release()
|
||||
return true
|
||||
}
|
||||
@@ -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__/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__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/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__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
@@ -1041,6 +1042,7 @@ export default function App() {
|
||||
const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal);
|
||||
const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal);
|
||||
const [desktopPlatform, setDesktopPlatform] = useState<DesktopPlatform>(detectBrowserPlatform);
|
||||
useWailsResizeFix(desktopPlatform === "windows");
|
||||
const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text");
|
||||
const [statusBarItems, setStatusBarItems] = useState<StatusBarItemId[]>(() => [...DEFAULT_STATUS_BAR_ITEMS]);
|
||||
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
|
||||
|
||||
@@ -54,7 +54,7 @@ async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000
|
||||
ok(false, label);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
function installDom(language = "en-US") {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
@@ -62,6 +62,7 @@ function installDom() {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
@@ -129,7 +130,7 @@ async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]
|
||||
console.log("\napproval modal file references");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const dom = installDom("en-US");
|
||||
mockApp({
|
||||
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
|
||||
SearchFileRefs: async () => [],
|
||||
@@ -178,6 +179,86 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-approval-zh",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("仅本次不进沙箱运行:go test ./..."), "sandbox escape approval localizes subject in Chinese UI");
|
||||
ok(text.includes("Windows 沙箱启动这条命令时失败"), "sandbox escape approval localizes English backend reason in Chinese UI");
|
||||
ok(text.includes("允许一次"), "sandbox escape Chinese approval shows allow once");
|
||||
ok(text.includes("本会话使用真实环境"), "sandbox escape Chinese approval shows session grant");
|
||||
ok(text.includes("拒绝"), "sandbox escape Chinese approval shows deny");
|
||||
ok(!text.includes("总是允许"), "sandbox escape Chinese approval hides persistent grant");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval-zh",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("保存记忆"), "remember approval localizes tool label in Chinese UI");
|
||||
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"), "remember approval localizes subject prefix in Chinese UI");
|
||||
ok(text.includes("正文: Use Vitest for frontend tests."), "remember approval localizes body label in Chinese UI");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "plan-mode-read-only-command-zh",
|
||||
tool: "plan_mode_read_only_command",
|
||||
subject: "Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
|
||||
reason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("计划模式只读命令"), "plan-mode read-only command approval localizes tool label in Chinese UI");
|
||||
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"), "plan-mode read-only command approval localizes subject in Chinese UI");
|
||||
ok(text.includes("不在 Reasonix 内置只读集合中"), "plan-mode read-only command approval localizes reason in Chinese UI");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
@@ -260,8 +341,8 @@ console.log("\napproval modal file references");
|
||||
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|4Deny",
|
||||
"fresh-human approval keeps allow/deny shortcut keys",
|
||||
"1Allow once|2Deny",
|
||||
"fresh-human approval keeps conventional allow/deny shortcut keys",
|
||||
);
|
||||
|
||||
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
|
||||
@@ -280,5 +361,108 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval-deny",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[false, false, false]]), "fresh-human numeric 2 denies");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-approval",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("bash sandbox escape"), "sandbox escape approval uses a clear tool label");
|
||||
ok(text.includes("Allow once"), "sandbox escape approval shows allow once");
|
||||
ok(text.includes("Use real environment for this session"), "sandbox escape approval shows session grant");
|
||||
ok(text.includes("Deny"), "sandbox escape approval shows deny");
|
||||
ok(!text.includes("Always allow"), "sandbox escape approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|2Use real environment for this session|3Deny",
|
||||
"sandbox escape approval keeps conventional allow once/session/deny shortcut keys",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(JSON.stringify(answers), JSON.stringify([{ allow: true, session: true, persist: false }]), "sandbox escape Enter on selected session action grants session");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-deny-approval",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(JSON.stringify(answers), JSON.stringify([{ allow: false, session: false, persist: false }]), "sandbox escape numeric 3 denies");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -27,6 +28,12 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nproject tree runtime sessions");
|
||||
|
||||
const testT = (key: string, vars?: Record<string, string | number>) => {
|
||||
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
|
||||
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
|
||||
return key;
|
||||
};
|
||||
|
||||
const tree: ProjectNode[] = [
|
||||
{
|
||||
key: "global_folder",
|
||||
@@ -107,6 +114,29 @@ eq(
|
||||
"regular project topic still opens by topic",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicMetaLine({
|
||||
key: "global_topic_missing_time",
|
||||
kind: "global_topic",
|
||||
label: "Old empty topic",
|
||||
topicId: "missing-time",
|
||||
}, testT),
|
||||
"projectTree.previously",
|
||||
"topic with no turns and no timestamps renders previous-time fallback meta",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicMetaLine({
|
||||
key: "global_topic_recent",
|
||||
kind: "global_topic",
|
||||
label: "Recent blank topic",
|
||||
topicId: "recent",
|
||||
createdAt: Date.now(),
|
||||
}, testT),
|
||||
"projectTree.justNow",
|
||||
"topic with a real recent timestamp still renders just-now meta",
|
||||
);
|
||||
|
||||
const completedTopic: ProjectNode = {
|
||||
key: "topic_complete",
|
||||
kind: "topic",
|
||||
|
||||
@@ -165,6 +165,7 @@ globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
window.scrollTo = () => {};
|
||||
localStorage.clear();
|
||||
|
||||
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
|
||||
let settingsCalls = 0;
|
||||
@@ -191,6 +192,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={(settings?: SettingsView) => {
|
||||
onChangedSettings = settings;
|
||||
@@ -238,6 +240,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
@@ -266,6 +269,55 @@ await act(async () => {
|
||||
retryRoot.unmount();
|
||||
});
|
||||
|
||||
const zoomRootEl = document.createElement("div");
|
||||
document.body.appendChild(zoomRootEl);
|
||||
const zoomRoot = createRoot(zoomRootEl);
|
||||
let persistedZoom = 0.5;
|
||||
const savedZoomFactors: number[] = [];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => baseSettings("standard"),
|
||||
GetDesktopZoomFactor: async () => persistedZoom,
|
||||
SetDesktopZoomFactor: async (factor: number) => {
|
||||
persistedZoom = factor;
|
||||
savedZoomFactors.push(factor);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
localStorage.setItem("reasonix-zoom-restart", "1");
|
||||
await act(async () => {
|
||||
zoomRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="appearance"
|
||||
desktopPlatform="windows"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
|
||||
|
||||
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
|
||||
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
|
||||
await act(async () => {
|
||||
resetZoomButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
|
||||
|
||||
eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
|
||||
eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
|
||||
|
||||
await act(async () => {
|
||||
zoomRoot.unmount();
|
||||
});
|
||||
|
||||
// Bots tab: direct four-channel bot manager.
|
||||
const botsRootEl = document.createElement("div");
|
||||
document.body.appendChild(botsRootEl);
|
||||
@@ -301,7 +353,7 @@ window.go = {
|
||||
await act(async () => {
|
||||
botsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
|
||||
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
@@ -133,9 +133,12 @@ const tabD = tabMeta("tab-d");
|
||||
const tabE = tabMeta("tab-e");
|
||||
const tabF = tabMeta("tab-f");
|
||||
const tabG = tabMeta("tab-g");
|
||||
const tabH = tabMeta("tab-h");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
let metaH = deferred<Meta>();
|
||||
let historyH = deferred<HistoryMessage[]>();
|
||||
const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
@@ -144,12 +147,15 @@ const submitTabCGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
let metaHCalls = 0;
|
||||
let holdNextContextForD = false;
|
||||
let holdNextMetaForH = false;
|
||||
let holdNextHistoryForH = false;
|
||||
let setActiveCalls = 0;
|
||||
let newSessionCalls = 0;
|
||||
let failSetActiveFor = "";
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
@@ -172,7 +178,14 @@ window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
MetaForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-h" && holdNextMetaForH) {
|
||||
metaHCalls += 1;
|
||||
holdNextMetaForH = false;
|
||||
return metaH.promise;
|
||||
}
|
||||
return metaFor(tabsById.get(tabID) ?? tabA);
|
||||
},
|
||||
ContextUsageForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-d" && holdNextContextForD) {
|
||||
contextDCalls += 1;
|
||||
@@ -191,6 +204,12 @@ window.go = {
|
||||
if (tabID === "tab-b") return historyB.promise;
|
||||
if (tabID === "tab-d") return historyD.promise;
|
||||
if (tabID === "tab-e") return [userMessage("fork E")];
|
||||
if (tabID === "tab-g") return [userMessage("history G")];
|
||||
if (tabID === "tab-h" && holdNextHistoryForH) {
|
||||
holdNextHistoryForH = false;
|
||||
return historyH.promise;
|
||||
}
|
||||
if (tabID === "tab-h") return [userMessage("history H")];
|
||||
return [userMessage("cached A")];
|
||||
},
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
@@ -198,9 +217,15 @@ window.go = {
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
OpenProjectTab: async () => {
|
||||
backendActiveId = "tab-d";
|
||||
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
|
||||
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
|
||||
backendActiveId = target.id;
|
||||
return { ...target, active: true };
|
||||
},
|
||||
ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
|
||||
backendActiveId = target.id;
|
||||
return { ...target, active: true };
|
||||
},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
@@ -509,6 +534,70 @@ await act(async () => {
|
||||
});
|
||||
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
|
||||
|
||||
metaH = deferred<Meta>();
|
||||
holdNextMetaForH = true;
|
||||
const historyCallsBeforeSlowMeta = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta tab hydrates history", () =>
|
||||
controller?.activeTabId === "tab-h" &&
|
||||
controller.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) &&
|
||||
metaHCalls === 1
|
||||
);
|
||||
eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
|
||||
eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
|
||||
|
||||
metaH = deferred<Meta>();
|
||||
const metaHCallsBeforeStale = metaHCalls;
|
||||
holdNextMetaForH = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("single-surface activation replaces visible tab", () =>
|
||||
controller?.activeTabId === "tab-g" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
historyH = deferred<HistoryMessage[]>();
|
||||
holdNextHistoryForH = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reopened tab-h treats stale late meta as discarded", () =>
|
||||
controller?.activeTabId === "tab-h" &&
|
||||
controller.state.hydrating === true &&
|
||||
(controller.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => {
|
||||
historyH.resolve([userMessage("history H after stale meta")]);
|
||||
await historyH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reopened tab-h finishes after stale meta discard", () =>
|
||||
controller?.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Run: tsx src/__tests__/turn-action-copy.test.ts
|
||||
|
||||
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nturn action copy");
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("", "final answer"),
|
||||
"final answer",
|
||||
"keeps single assistant output unchanged",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block", "second assistant block"),
|
||||
"first assistant block\n\nsecond assistant block",
|
||||
"accumulates multiple assistant outputs in one turn",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block\n", "second assistant block"),
|
||||
"first assistant block\nsecond assistant block",
|
||||
"does not add an extra blank line when the previous block already ends with newline",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block", "\nsecond assistant block"),
|
||||
"first assistant block\nsecond assistant block",
|
||||
"does not add an extra blank line when the next block starts with newline",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("final answer", " \n\t"),
|
||||
"final answer",
|
||||
"ignores whitespace-only assistant fragments",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -102,6 +102,7 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
let backendRunning = false;
|
||||
let cancelCalls = 0;
|
||||
let effortCalls = 0;
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
|
||||
@@ -119,6 +120,10 @@ window.go = {
|
||||
MetaForTab: async () => meta(),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
SetEffortForTab: async () => {
|
||||
effortCalls += 1;
|
||||
throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
|
||||
},
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
@@ -178,6 +183,15 @@ eq(controller?.state.running, false, "cancel reconciliation clears the running s
|
||||
eq(cancelCalls, 1, "CancelTab is called once");
|
||||
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.setEffort("max");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
|
||||
eq(effortCalls, 1, "SetEffortForTab is called once");
|
||||
ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import type { Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
@@ -82,6 +82,34 @@ function usage(source: string): WireUsage {
|
||||
|
||||
console.log("\nuse controller meta");
|
||||
|
||||
{
|
||||
eq(
|
||||
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
|
||||
"Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"effort busy guard is worded as temporary",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("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 effort"),
|
||||
"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.",
|
||||
"effort lease conflict explains the safe path",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("workspace is still starting"),
|
||||
"This session is still starting. Try changing reasoning effort again in a moment.",
|
||||
"effort startup race asks the user to retry later",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
|
||||
"The current session changed while switching reasoning effort. Try once more.",
|
||||
"effort tab race asks the user to retry",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("unknown model \"missing\""),
|
||||
"Reasoning effort switch failed: unknown model \"missing\"",
|
||||
"effort true failure keeps the underlying error",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Run: tsx src/__tests__/wails-resize-fix.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useWailsResizeFix } from "../lib/useWailsResizeFix";
|
||||
|
||||
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 installDom(): JSDOM {
|
||||
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.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 100 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 80 });
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installWailsFlags(flags?: Partial<NonNullable<Window["wails"]>["flags"]>) {
|
||||
window.wails = {
|
||||
Callback: () => undefined,
|
||||
EventsNotify: () => undefined,
|
||||
flags: {
|
||||
enableResize: true,
|
||||
resizeEdge: undefined,
|
||||
borderThickness: 6,
|
||||
defaultCursor: null,
|
||||
cssDragProperty: "--wails-draggable",
|
||||
cssDragValue: "drag",
|
||||
cssDropProperty: "--wails-drop-target",
|
||||
cssDropValue: "drop",
|
||||
shouldDrag: false,
|
||||
deferDragToMouseMove: false,
|
||||
disableScrollbarDrag: false,
|
||||
disableDefaultContextMenu: false,
|
||||
enableWailsDragAndDrop: false,
|
||||
...flags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ enabled }: { enabled: boolean }) {
|
||||
useWailsResizeFix(enabled);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, enabled: boolean) {
|
||||
await act(async () => {
|
||||
root.render(<Harness enabled={enabled} />);
|
||||
});
|
||||
}
|
||||
|
||||
async function unmount(root: Root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nwails resize fix");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
await renderHarness(root, false);
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
|
||||
eq(window.wails?.flags.enableResize, true, "disabled hook leaves Wails resize enabled");
|
||||
eq(window.wails?.flags.resizeEdge, "n-resize", "disabled hook leaves resize edge unchanged");
|
||||
|
||||
await unmount(root);
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
document.documentElement.style.cursor = "grab";
|
||||
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
await renderHarness(root, true);
|
||||
eq(window.wails?.flags.enableResize, false, "enabled hook disables Wails built-in mousemove resize");
|
||||
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
|
||||
eq(window.wails?.flags.resizeEdge, "e-resize", "enabled hook detects right edge using innerWidth CSS pixels");
|
||||
eq(document.documentElement.style.cursor, "e-resize", "enabled hook mirrors detected edge cursor");
|
||||
|
||||
await unmount(root);
|
||||
eq(window.wails?.flags.enableResize, true, "cleanup restores previous enableResize");
|
||||
eq(window.wails?.flags.resizeEdge, "n-resize", "cleanup restores previous resizeEdge");
|
||||
eq(document.documentElement.style.cursor, "grab", "cleanup restores previous cursor");
|
||||
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
@@ -28,7 +28,89 @@ function animateShelfExit(
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
|
||||
}
|
||||
|
||||
function approvalToolLabel(tool: string, t: Translator): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return t("approval.toolLabelBash");
|
||||
case "edit_file":
|
||||
return t("approval.toolLabelEditFile");
|
||||
case "write_file":
|
||||
return t("approval.toolLabelWriteFile");
|
||||
case "multi_edit":
|
||||
return t("approval.toolLabelMultiEdit");
|
||||
case "move_file":
|
||||
return t("approval.toolLabelMoveFile");
|
||||
case "web_fetch":
|
||||
return t("approval.toolLabelWebFetch");
|
||||
case "run_skill":
|
||||
return t("approval.toolLabelRunSkill");
|
||||
case "remember":
|
||||
return t("approval.toolLabelRemember");
|
||||
case "forget":
|
||||
return t("approval.toolLabelForget");
|
||||
case "sandbox_escape":
|
||||
return t("approval.toolLabelSandboxEscape");
|
||||
case "plan_mode_read_only_command":
|
||||
return t("approval.toolLabelPlanModeReadOnly");
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
|
||||
const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
|
||||
const planModeMcpEnglishSubject = /^MCP (.+) as read-only for planning and research$/;
|
||||
const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
|
||||
|
||||
function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
|
||||
const trimmed = subject.trim();
|
||||
if (tool === "sandbox_escape") {
|
||||
if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
|
||||
if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
|
||||
return `${t("approval.sandboxEscapeSubjectPrefix")}${trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
if (tool === "remember") {
|
||||
return trimmed
|
||||
.replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
|
||||
.replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
|
||||
}
|
||||
if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
|
||||
return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
|
||||
}
|
||||
const mcpTrust = trimmed.match(planModeMcpEnglishSubject);
|
||||
if (mcpTrust) {
|
||||
return t("approval.planModeMcpTrustSubject", { target: mcpTrust[1] ?? "" });
|
||||
}
|
||||
const bashTrust = trimmed.match(planModeBashEnglishSubject);
|
||||
if (bashTrust) {
|
||||
return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
|
||||
const trimmed = reason?.trim() ?? "";
|
||||
if (tool !== "sandbox_escape") return trimmed;
|
||||
if (trimmed.includes("could not wrap this command")) return t("approval.sandboxEscapeWrapReason");
|
||||
if (trimmed.includes("failed while starting this command") || trimmed.includes("Run this command unconfined once?")) {
|
||||
return t("approval.sandboxEscapeRuntimeReason");
|
||||
}
|
||||
return trimmed || t("approval.sandboxEscapeRuntimeReason");
|
||||
}
|
||||
|
||||
function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
|
||||
if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
|
||||
return t("approval.planModeBashTrustReason");
|
||||
}
|
||||
if (reason.includes("external read-only hints need your confirmation")) {
|
||||
return t("approval.planModeMcpTrustReason");
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
@@ -52,13 +134,14 @@ export function ApprovalModal({
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
|
||||
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
|
||||
const toolLabel = approvalToolLabel(approval.tool, t);
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const subject = approval.subject.trim();
|
||||
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
|
||||
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
|
||||
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(approval.reason || subject);
|
||||
const toolMeta = reason || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(reason || subject);
|
||||
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
|
||||
const [revisionOpen, setRevisionOpen] = useState(false);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
@@ -100,6 +183,9 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
|
||||
else if (hasFreshSessionGrant && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (hasFreshSessionGrant && key === "3") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
@@ -107,6 +193,12 @@ export function ApprovalModal({
|
||||
else if (key === "Escape") answerWithExit(onStop);
|
||||
};
|
||||
|
||||
const selectedToolActionKey = (index: number) => {
|
||||
if (!isFreshHumanApproval) return String(index + 1);
|
||||
if (hasFreshSessionGrant) return index === 0 ? "1" : index === 1 ? "2" : "3";
|
||||
return index === 0 ? "1" : "2";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
cardRef.current?.focus();
|
||||
setRevisionOpen(false);
|
||||
@@ -116,7 +208,7 @@ export function ApprovalModal({
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -137,8 +229,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
|
||||
else chooseToolAction(key);
|
||||
else chooseToolAction(selectedToolActionKey(selectedIndexRef.current));
|
||||
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (isPlanApproval) choosePlanAction(event.key);
|
||||
@@ -147,7 +238,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) {
|
||||
@@ -318,7 +409,14 @@ export function ApprovalModal({
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
{isFreshHumanApproval ? (
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
hasFreshSessionGrant ? (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowSandboxEscapeSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} />
|
||||
</>
|
||||
) : (
|
||||
<PromptAction keyLabel="2" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
@@ -331,7 +429,7 @@ export function ApprovalModal({
|
||||
>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
|
||||
@@ -470,6 +470,7 @@ export function HistoryPanel({
|
||||
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
|
||||
{!isTrash && !s.current && s.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
|
||||
{isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
|
||||
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
|
||||
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
|
||||
<span className="hist-item__metaspacer" />
|
||||
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -123,16 +123,20 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR
|
||||
);
|
||||
}
|
||||
|
||||
function topicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
const parts: string[] = [];
|
||||
const turns = node.turns ?? 0;
|
||||
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
|
||||
if (parts.length === 0) parts.push(t("projectTree.justNow"));
|
||||
if (parts.length === 0) parts.push(t("projectTree.previously"));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
|
||||
return topicActivityAt(node) ? "" : t("projectTree.previously");
|
||||
}
|
||||
|
||||
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
|
||||
thinking: "projectTree.status.thinking",
|
||||
streaming: "projectTree.status.streaming",
|
||||
@@ -1050,8 +1054,10 @@ export function ProjectTree({
|
||||
const filtered = tree
|
||||
.map(filterNode)
|
||||
.filter((node): node is ProjectNode => node !== null);
|
||||
return compactTopics ? arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode) : filtered;
|
||||
}, [compactTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
|
||||
if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode);
|
||||
if (creationTopics) return arrangeWorkbenchTree(filtered, "project", "updated");
|
||||
return filtered;
|
||||
}, [compactTopics, creationTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
|
||||
|
||||
const workbenchTreeSections = useMemo<WorkbenchTreeSections>(() => {
|
||||
if (!compactTopics) return { pinned: [], projects: visibleTree };
|
||||
@@ -1126,11 +1132,12 @@ export function ProjectTree({
|
||||
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
|
||||
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
|
||||
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
const sideTimeVisible = compactTopics || creationTopics;
|
||||
const timeLabel = sideTimeVisible && activityAt ? topicActivityLabel(activityAt, t, true) : "";
|
||||
const timeLabel = sideTimeVisible ? (activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t)) : "";
|
||||
const exactTimeLabel = sideTimeVisible && activityAt ? topicActivityDateLabel(activityAt) : "";
|
||||
const meta = topicMetaLine(node, t, compactTopics);
|
||||
const meta = projectTreeTopicMetaLine(node, t, compactTopics);
|
||||
const status = topicStatus(node);
|
||||
const statusLabel = topicStatusLabel(node, t);
|
||||
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
|
||||
@@ -1140,15 +1147,8 @@ export function ProjectTree({
|
||||
const imSourceLabel = imSource?.label || "";
|
||||
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
|
||||
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
|
||||
const recovered = Boolean(node.recovered);
|
||||
const recoveryTitle = recovered
|
||||
? [
|
||||
t("recovery.branch"),
|
||||
node.recoveryReason ? t("recovery.reason", { reason: node.recoveryReason }) : "",
|
||||
node.recoveryDigest ? t("recovery.digest", { digest: node.recoveryDigest.slice(0, 12) }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const title = [label, recoveryTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
|
||||
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
|
||||
const pinned = Boolean(node.pinned);
|
||||
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
|
||||
@@ -1225,7 +1225,7 @@ export function ProjectTree({
|
||||
}
|
||||
const row = (
|
||||
<div
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${recovered ? " project-tree__topic--recovered" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
style={accentStyle}
|
||||
onContextMenu={isSessionNode ? undefined : openTopicMenu}
|
||||
>
|
||||
@@ -1267,7 +1267,7 @@ export function ProjectTree({
|
||||
>
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-heading">
|
||||
<span className="project-tree__topic-label">{label}</span>
|
||||
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
|
||||
{imSource && (
|
||||
<span
|
||||
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
|
||||
@@ -1279,11 +1279,6 @@ export function ProjectTree({
|
||||
</span>
|
||||
)}
|
||||
{!compactTopics && statusLabel && <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>}
|
||||
{recovered && (
|
||||
<span className="project-tree__topic-recovery" title={recoveryTitle}>
|
||||
{t("recovery.badge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!compactTopics && !creationTopics && meta && (
|
||||
<span className="project-tree__topic-meta">
|
||||
@@ -1307,11 +1302,6 @@ export function ProjectTree({
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
{compactTopics && recovered && (
|
||||
<span className="sr-only">
|
||||
{t("recovery.branch")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
|
||||
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react";
|
||||
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Play, QrCode, RefreshCw, Send } from "lucide-react";
|
||||
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Minus, Play, Plus, QrCode, RefreshCw, RotateCcw, Send } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { useDeferredClose } from "../lib/useMountTransition";
|
||||
import { app } from "../lib/bridge";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ThemeStyle,
|
||||
} from "../lib/theme";
|
||||
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
|
||||
import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
|
||||
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP, snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
|
||||
import {
|
||||
applyFontFamily,
|
||||
applyMonoFontFamily,
|
||||
@@ -102,6 +102,7 @@ export function SettingsPanel({
|
||||
const [tab, setTab] = useState<SettingsTab>(initialTab === "providers" ? "models" : initialTab ?? "general");
|
||||
// Play the modal exit animation, then let the parent unmount us.
|
||||
const { status, requestClose } = useDeferredClose(onClose, 240);
|
||||
const zoomSaveSeq = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoadingSettings(true);
|
||||
@@ -129,6 +130,24 @@ export function SettingsPanel({
|
||||
setThemeState(nextTheme);
|
||||
setThemeStyleState(nextStyle);
|
||||
}, [s?.desktopTheme, s?.desktopThemeStyle]);
|
||||
useEffect(() => {
|
||||
if (desktopPlatform !== "windows") return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const persisted = await app.GetDesktopZoomFactor();
|
||||
if (cancelled || typeof persisted !== "number" || !Number.isFinite(persisted)) return;
|
||||
const snapped = snapZoom(persisted);
|
||||
saveRestartZoom(snapped);
|
||||
setZoomPct(zoomToPercent(snapped));
|
||||
} catch {
|
||||
// Older mocks or startup races can lack the binding; keep the local fallback.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [desktopPlatform]);
|
||||
|
||||
// apply runs a mutation, re-reads settings, and refreshes the topbar/model.
|
||||
const apply = useCallback(async (fn: () => Promise<unknown>) => {
|
||||
@@ -159,6 +178,21 @@ export function SettingsPanel({
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
}
|
||||
}, [reload, onChanged]);
|
||||
const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
|
||||
const snapped = snapZoom(zoom);
|
||||
const seq = ++zoomSaveSeq.current;
|
||||
setErr(null);
|
||||
setWarning(null);
|
||||
setZoomPct(zoomToPercent(snapped));
|
||||
try {
|
||||
await app.SetDesktopZoomFactor(snapped);
|
||||
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
|
||||
} catch (e) {
|
||||
if (seq !== zoomSaveSeq.current) return;
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
setZoomPct(zoomToPercent(getRestartZoom()));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close on Esc
|
||||
useEffect(() => {
|
||||
@@ -248,18 +282,7 @@ export function SettingsPanel({
|
||||
applyTextSize(size);
|
||||
setTextSizeState(size);
|
||||
}}
|
||||
onRestartZoom={async (zoom) => {
|
||||
const snapped = snapZoom(zoom);
|
||||
setErr(null);
|
||||
setWarning(null);
|
||||
try {
|
||||
await app.SetDesktopZoomFactor(snapped);
|
||||
saveRestartZoom(snapped);
|
||||
setZoomPct(zoomToPercent(snapped));
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
}
|
||||
}}
|
||||
onRestartZoom={setRestartZoom}
|
||||
onFontFamily={(font) => {
|
||||
applyFontFamily(font);
|
||||
setFontFamilyState(font);
|
||||
@@ -4479,7 +4502,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
setGroupModelDraft(group.id, null);
|
||||
try {
|
||||
await apply(async () => {
|
||||
await app.SetProviderKey(apiKeyEnv, value);
|
||||
await app.SaveProviderKey(apiKeyEnv, value);
|
||||
try {
|
||||
const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv });
|
||||
if (fetched.length > 0) {
|
||||
@@ -4585,7 +4608,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
setAdding(null);
|
||||
}}
|
||||
onResetPreset={(id) => apply(() => app.ResetProviderPresetAccess(id)).then(() => setAdding(null))}
|
||||
onAddCustom={(pv) => apply(() => app.SaveProvider(pv)).then(() => setAdding(null))}
|
||||
onAddCustom={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => setAdding(null))}
|
||||
/>
|
||||
)}
|
||||
{adding === null && groups.map((group) => (
|
||||
@@ -4601,7 +4624,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
kinds={s.providerKinds}
|
||||
onEdit={setEditing}
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
onSave={(pv) => apply(() => app.SaveProvider(pv)).then(() => {
|
||||
onSave={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => {
|
||||
setEditing(null);
|
||||
setGroupModelDraft(group.id, null);
|
||||
})}
|
||||
@@ -4705,6 +4728,10 @@ function providerTemplateConflictProviderName(choice: ProviderTemplateChoice): s
|
||||
|
||||
function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typeof useT>): string {
|
||||
switch (preset.id) {
|
||||
case "longcat-openai":
|
||||
return t("settings.addProvider.preset.longcatOpenAIDesc");
|
||||
case "longcat-anthropic":
|
||||
return t("settings.addProvider.preset.longcatAnthropicDesc");
|
||||
case "kimi-cn":
|
||||
return t("settings.addProvider.preset.kimiCnDesc");
|
||||
case "kimi-global":
|
||||
@@ -4811,7 +4838,7 @@ function AddProviderPanel({
|
||||
onAddPreset: (id: string, key: string) => Promise<void>;
|
||||
onViewPresetConflict: (providerName: string) => void;
|
||||
onResetPreset: (id: string) => Promise<void>;
|
||||
onAddCustom: (p: ProviderView) => void | Promise<void>;
|
||||
onAddCustom: (p: ProviderView, key?: string) => void | Promise<void>;
|
||||
}) {
|
||||
const t = useT();
|
||||
const templateChoices = useMemo<ProviderTemplateChoice[]>(() => [
|
||||
@@ -5026,7 +5053,7 @@ function ProviderAccessCard({
|
||||
kinds: string[];
|
||||
onEdit: (name: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSave: (p: ProviderView) => void | Promise<void>;
|
||||
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
|
||||
onRefresh: () => void;
|
||||
onToggleDraftModel: (model: string) => void;
|
||||
onToggleDraftVision: (model: string) => void;
|
||||
@@ -5517,7 +5544,7 @@ function ProviderEditor({
|
||||
kinds: string[];
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (p: ProviderView) => void;
|
||||
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
|
||||
onSaveKey?: (apiKeyEnv: string, value: string) => Promise<void>;
|
||||
onClearKey?: (apiKeyEnv: string) => Promise<void>;
|
||||
}) {
|
||||
@@ -5594,7 +5621,7 @@ function ProviderEditor({
|
||||
try {
|
||||
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
|
||||
if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
|
||||
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
if (keyDraft.trim()) await app.SaveProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
const fetched = await app.FetchProviderModels({
|
||||
name: name.trim() || t("settings.newProviderDraftName"),
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
@@ -5642,11 +5669,12 @@ function ProviderEditor({
|
||||
|
||||
const save = async () => {
|
||||
if (extraBodyInvalid) return;
|
||||
setFetchStatus(null);
|
||||
setFetchFallback(null);
|
||||
const ms = parseProviderListInput(models);
|
||||
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
|
||||
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
|
||||
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
onSave({
|
||||
const provider: ProviderView = {
|
||||
name: name.trim(),
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
added: initial?.added ?? true,
|
||||
@@ -5672,7 +5700,12 @@ function ProviderEditor({
|
||||
// NormalizeEffort would otherwise silently ignore an unsupported value.
|
||||
defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
|
||||
modelOverrides: initial?.modelOverrides ?? [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
await onSave(provider, keyDraft.trim() || undefined);
|
||||
} catch (e) {
|
||||
setFetchFallback(String((e as Error)?.message ?? e));
|
||||
}
|
||||
};
|
||||
|
||||
if (builtIn) {
|
||||
@@ -6582,6 +6615,15 @@ function AppearanceSection({
|
||||
const themeOptions: Theme[] = ["auto", "light", "dark"];
|
||||
const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
|
||||
const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
|
||||
const zoomMinPct = zoomToPercent(MIN_ZOOM);
|
||||
const zoomMaxPct = zoomToPercent(MAX_ZOOM);
|
||||
const zoomStepPct = Math.round(ZOOM_STEP * 100);
|
||||
const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
|
||||
const canDecreaseZoom = zoomPct > zoomMinPct;
|
||||
const canIncreaseZoom = zoomPct < zoomMaxPct;
|
||||
const setZoomPercent = (pct: number) => {
|
||||
void onRestartZoom(pct / 100);
|
||||
};
|
||||
return (
|
||||
<SettingsSection title={t("settings.appearance")}>
|
||||
<SettingsField label={t("settings.theme")}>
|
||||
@@ -6647,30 +6689,62 @@ function AppearanceSection({
|
||||
{showDisplayZoom && (
|
||||
<SettingsField label={t("settings.displayZoom")}>
|
||||
<div className="zoom-slider-wrap">
|
||||
<div className="zoom-slider__value">{zoomPct}%</div>
|
||||
<div className="zoom-slider__head">
|
||||
<div className="zoom-slider__value">{zoomPct}%</div>
|
||||
<div className="zoom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomDecrease")}
|
||||
title={t("settings.displayZoomDecrease")}
|
||||
disabled={!canDecreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
|
||||
>
|
||||
<Minus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__reset"
|
||||
aria-label={t("settings.displayZoomReset")}
|
||||
title={t("settings.displayZoomReset")}
|
||||
disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
|
||||
onClick={() => { void onRestartZoom(DEFAULT_ZOOM); }}
|
||||
>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
<span>100%</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomIncrease")}
|
||||
title={t("settings.displayZoomIncrease")}
|
||||
disabled={!canIncreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
|
||||
>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="zoom-slider-row">
|
||||
<span className="zoom-slider__label">50%</span>
|
||||
<span className="zoom-slider__label">{zoomMinPct}%</span>
|
||||
<div className="slider-track">
|
||||
<div className="slider-track__bg" />
|
||||
<div
|
||||
className="slider-track__fill"
|
||||
style={{ width: `calc(${((zoomPct - 50) / 150) * 100}% + 15px)` }}
|
||||
style={{ width: `calc(${zoomProgressPct}% + 15px)` }}
|
||||
/>
|
||||
<div className="slider-thumb" style={{ left: `${((zoomPct - 50) / 150) * 100}%` }}>
|
||||
<div className="slider-thumb__left" />
|
||||
<div className="slider-thumb__mid" />
|
||||
<div className="slider-thumb__right" />
|
||||
</div>
|
||||
<div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
|
||||
<input
|
||||
aria-label={t("settings.displayZoom")}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
min={zoomMinPct}
|
||||
max={zoomMaxPct}
|
||||
step={zoomStepPct}
|
||||
value={zoomPct}
|
||||
onChange={(e) => { void onRestartZoom(Number(e.target.value) / 100); }}
|
||||
onChange={(e) => setZoomPercent(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<span className="zoom-slider__label">200%</span>
|
||||
<span className="zoom-slider__label">{zoomMaxPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
@@ -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";
|
||||
@@ -91,7 +91,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
app.ToolResultForTab(tabId, item.id).then((d) => {
|
||||
if (!cancelled && d) setFullData(d);
|
||||
}).catch(() => {}),
|
||||
);
|
||||
).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, item.id, item.dataArchived, fullData, tabId]);
|
||||
|
||||
@@ -127,7 +127,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
aria-expanded={hasBody ? open : undefined}
|
||||
>
|
||||
<span className="tool__label-group">
|
||||
{hasNested && <span className="tool__nested-count">⊞{nested.length}</span>}
|
||||
{hasNested && (
|
||||
<span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
|
||||
<Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<span>{nested.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
|
||||
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
|
||||
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
|
||||
|
||||
@@ -14,7 +14,8 @@ 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" }>;
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
@@ -314,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 ───────────────────────────────────────────────────
|
||||
@@ -390,6 +392,7 @@ export function Transcript({
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
hoverMenus={actionHoverMenus}
|
||||
isLastTurn={turn === lastTurn}
|
||||
onRewind={(targetTurn, scope) => {
|
||||
onRewind?.(targetTurn, scope);
|
||||
setOpenAction(null);
|
||||
@@ -500,7 +503,7 @@ export function Transcript({
|
||||
/>,
|
||||
);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
}
|
||||
@@ -573,7 +576,7 @@ export function Transcript({
|
||||
case "assistant":
|
||||
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
break;
|
||||
@@ -593,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
|
||||
@@ -631,6 +634,7 @@ export function Transcript({
|
||||
warmSubcalls={subcallsByParent}
|
||||
warmUserTurn={userTurn}
|
||||
warmCheckpoints={checkpointsByTurn}
|
||||
warmLastTurn={lastTurn}
|
||||
warmOpenAction={openAction}
|
||||
warmActionPending={actionPending}
|
||||
warmRewindDisabled={rewindDisabled}
|
||||
@@ -686,6 +690,7 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSubcalls,
|
||||
warmUserTurn,
|
||||
warmCheckpoints,
|
||||
warmLastTurn,
|
||||
warmOpenAction,
|
||||
warmActionPending,
|
||||
warmRewindDisabled,
|
||||
@@ -708,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;
|
||||
@@ -773,6 +779,7 @@ const WarmZone = memo(function WarmZone({
|
||||
onEdit={warmOnEdit}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
lastTurn={warmLastTurn}
|
||||
/>
|
||||
</WarmTurnCard>,
|
||||
);
|
||||
@@ -820,6 +827,7 @@ function WarmTurnItems({
|
||||
onEdit,
|
||||
tabId,
|
||||
creationMode = false,
|
||||
lastTurn,
|
||||
}: {
|
||||
startIdx: number;
|
||||
endIdx: number;
|
||||
@@ -836,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 = "";
|
||||
@@ -856,6 +865,7 @@ function WarmTurnItems({
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
hoverMenus={actionHoverMenus}
|
||||
isLastTurn={turn === lastTurn}
|
||||
onRewind={(targetTurn, scope) => {
|
||||
onRewind?.(targetTurn, scope);
|
||||
setOpenAction(null);
|
||||
@@ -926,7 +936,7 @@ function WarmTurnItems({
|
||||
case "assistant": {
|
||||
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -193,7 +193,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [open, startNew]);
|
||||
|
||||
@@ -210,18 +210,22 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((task: HeartbeatTask) => {
|
||||
@@ -238,8 +242,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
|
||||
const handleTrigger = useCallback(
|
||||
async (id: string) => {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
try {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadTasks],
|
||||
);
|
||||
|
||||
@@ -294,12 +294,14 @@ export interface AppBindings {
|
||||
SetAutoPlan(mode: string): Promise<void>;
|
||||
SetDefaultToolApprovalMode(mode: string): Promise<void>;
|
||||
SaveProvider(p: ProviderView): Promise<void>;
|
||||
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
|
||||
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
|
||||
AddProviderPresetAccess(id: string, key: string): Promise<string>;
|
||||
ResetProviderPresetAccess(id: string): Promise<void>;
|
||||
FetchProviderModels(p: ProviderView): Promise<string[]>;
|
||||
DeleteProvider(name: string): Promise<void>;
|
||||
RemoveProviderAccess(name: string): Promise<void>;
|
||||
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
|
||||
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
|
||||
ClearProviderKey(apiKeyEnv: string): Promise<void>;
|
||||
SetPermissionMode(mode: string): Promise<void>;
|
||||
@@ -612,7 +614,7 @@ function bridgeBreadcrumb(method: string): string {
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
return `provider ${method}`;
|
||||
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
|
||||
@@ -717,12 +719,13 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
|
||||
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
|
||||
}
|
||||
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
|
||||
if (typeof window === "undefined") return "demo";
|
||||
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
|
||||
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
|
||||
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
|
||||
if (value === "running" || value === "busy" || value === "streaming") return "running";
|
||||
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
@@ -766,6 +769,7 @@ function mockPreset(id: string, label: string, description: string, keyEnv: stri
|
||||
}
|
||||
|
||||
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
|
||||
const mockLongCatModels = ["LongCat-2.0"];
|
||||
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
|
||||
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
|
||||
@@ -784,6 +788,8 @@ const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-
|
||||
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
|
||||
|
||||
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
|
||||
@@ -826,7 +832,7 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
];
|
||||
|
||||
function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
return mockProviderPresetTemplates.map((template) => ({
|
||||
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
@@ -842,6 +848,14 @@ function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function mockProviderPresetDisplayRank(id: string): number {
|
||||
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
|
||||
if (id.startsWith("longcat-")) return 1;
|
||||
if (id.startsWith("kimi-")) return 2;
|
||||
if (id.startsWith("minimax-")) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
|
||||
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
|
||||
if (!template) return undefined;
|
||||
@@ -859,6 +873,7 @@ function makeMockApp(): AppBindings {
|
||||
const freshMock = scenario === "fresh";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const sandboxEscapeMock = scenario === "sandbox_escape";
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
@@ -867,6 +882,7 @@ function makeMockApp(): AppBindings {
|
||||
let cwd = freshMock ? globalWorkspaceRoot : "~/projects/joyquant-db"; // mutable so PickWorkspace is visible in dev
|
||||
let workspaces = freshMock ? [] : ["~/projects/joyquant-db", "~/projects/joyquant-sys", "~/projects/reasonix", "~/projects/blade"];
|
||||
let mockEffort = "auto";
|
||||
let mockDesktopZoomFactor = 1.0;
|
||||
const day = 86_400_000;
|
||||
const t0 = Date.now();
|
||||
// Mutable so MCP add/remove/retry are observable in browser dev.
|
||||
@@ -1620,6 +1636,23 @@ function makeMockApp(): AppBindings {
|
||||
cwd: "~/projects/joyquant-db",
|
||||
},
|
||||
];
|
||||
if (sandboxEscapeMock) {
|
||||
window.setTimeout(() => {
|
||||
if (pendingApprovalPreview) return;
|
||||
pendingApprovalPreview = true;
|
||||
emitMockTurnStarted();
|
||||
emit({ kind: "reasoning", text: t("mock.sandboxEscapeReasoning") });
|
||||
emit({
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "mock-sandbox-escape-preview",
|
||||
tool: "sandbox_escape",
|
||||
subject: t("mock.sandboxEscapeSubject"),
|
||||
reason: t("mock.sandboxEscapeReason"),
|
||||
},
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
const mockModelCatalog = [
|
||||
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
|
||||
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
|
||||
@@ -1716,6 +1749,26 @@ function makeMockApp(): AppBindings {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
trimmedInput === "/sandbox-escape-preview" ||
|
||||
trimmedInput === "sandbox escape preview" ||
|
||||
trimmedInput === "sandbox_escape preview" ||
|
||||
trimmedInput === "sandbox escape预览"
|
||||
) {
|
||||
pendingApprovalPreview = true;
|
||||
await delay(250);
|
||||
if (cancelled) return;
|
||||
emit({
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "mock-sandbox-escape-preview",
|
||||
tool: "sandbox_escape",
|
||||
subject: t("mock.sandboxEscapeSubject"),
|
||||
reason: t("mock.sandboxEscapeReason"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
trimmedInput === "/plan-approve-preview" ||
|
||||
trimmedInput === "plan approve preview" ||
|
||||
@@ -1830,6 +1883,67 @@ function makeMockApp(): AppBindings {
|
||||
emitMockTurnDone();
|
||||
return;
|
||||
}
|
||||
if (trimmedInput === "/nested-preview" || trimmedInput === "nested preview" || trimmedInput === "嵌套预览") {
|
||||
const parentId = "mock-nested-explore";
|
||||
await delay(180);
|
||||
if (cancelled) return;
|
||||
emit({
|
||||
kind: "reasoning",
|
||||
text: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
|
||||
});
|
||||
emit({
|
||||
kind: "message",
|
||||
text: "",
|
||||
reasoning: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
|
||||
});
|
||||
emit({
|
||||
kind: "tool_dispatch",
|
||||
tool: {
|
||||
id: parentId,
|
||||
name: "explore",
|
||||
args: JSON.stringify({ task: "在 Reasonix 前端中检查工具调用图标和嵌套调用展示" }),
|
||||
readOnly: true,
|
||||
profile: { model: "mock-reasonix", effort: "high" },
|
||||
},
|
||||
});
|
||||
for (let i = 1; i <= 30; i += 1) {
|
||||
if (cancelled) return;
|
||||
const id = `mock-nested-${i}`;
|
||||
const isSearch = i % 3 === 0;
|
||||
const name = isSearch ? "grep" : "read_file";
|
||||
const args = isSearch
|
||||
? { pattern: i % 2 === 0 ? "tool__nested-count" : "explore", path: "desktop/frontend/src" }
|
||||
: { path: `desktop/frontend/src/${i % 2 === 0 ? "components/ToolCard.tsx" : "styles.css"}`, offset: i * 10, limit: 40 };
|
||||
emit({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify(args), readOnly: true, parentId } });
|
||||
emit({
|
||||
kind: "tool_result",
|
||||
tool: {
|
||||
id,
|
||||
name,
|
||||
readOnly: true,
|
||||
output: isSearch ? "3 matches" : "read 40 lines",
|
||||
durationMs: 24 + i,
|
||||
},
|
||||
});
|
||||
await delay(18);
|
||||
}
|
||||
emit({
|
||||
kind: "tool_result",
|
||||
tool: {
|
||||
id: parentId,
|
||||
name: "explore",
|
||||
readOnly: true,
|
||||
output: "已读 20 个文件 · 搜索 10 个文件",
|
||||
durationMs: 61510,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
kind: "message",
|
||||
text: "Mock nested tool preview complete. The explore row now shows the compass count marker.",
|
||||
});
|
||||
emitMockTurnDone();
|
||||
return;
|
||||
}
|
||||
// Simulate the server's pre-first-token latency so the deferred user bubble
|
||||
// and the "un-send on Esc before any reply" path are observable in browser
|
||||
// dev. Bail if cancelled during the wait — nothing was streamed yet.
|
||||
@@ -2958,6 +3072,14 @@ function makeMockApp(): AppBindings {
|
||||
if (i >= 0) settings.providers[i] = p;
|
||||
else settings.providers.push(p);
|
||||
},
|
||||
async SaveProviderWithKey(p: ProviderView, key: string) {
|
||||
p.added = true;
|
||||
p.keySet = Boolean(key.trim()) || p.keySet;
|
||||
const i = settings.providers.findIndex((x) => x.name === p.name);
|
||||
if (i >= 0) settings.providers[i] = p;
|
||||
else settings.providers.push(p);
|
||||
return "";
|
||||
},
|
||||
async AddOfficialProviderAccess(kind: string, key: string) {
|
||||
const templates: Record<string, ProviderView> = {
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
@@ -3020,6 +3142,12 @@ function makeMockApp(): AppBindings {
|
||||
if (p?.builtIn) p.added = false;
|
||||
else settings.providers = settings.providers.filter((x) => x.name !== name);
|
||||
},
|
||||
async SaveProviderKey(apiKeyEnv: string, _value: string) {
|
||||
settings.providers.forEach((p) => {
|
||||
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
|
||||
});
|
||||
return "";
|
||||
},
|
||||
async SetProviderKey(apiKeyEnv: string, _value: string) {
|
||||
settings.providers.forEach((p) => {
|
||||
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
|
||||
@@ -3172,11 +3300,11 @@ function makeMockApp(): AppBindings {
|
||||
async SetDesktopLayoutStyle(style: string) {
|
||||
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
|
||||
},
|
||||
async SetDesktopZoomFactor(_factor: number) {
|
||||
// no-op in mock; in production this writes desktop-zoom.json via Go
|
||||
async SetDesktopZoomFactor(factor: number) {
|
||||
mockDesktopZoomFactor = Math.min(2.0, Math.max(0.5, Number.isFinite(factor) ? factor : 1.0));
|
||||
},
|
||||
async GetDesktopZoomFactor() {
|
||||
return 1.0; // default in mock
|
||||
return mockDesktopZoomFactor;
|
||||
},
|
||||
async RestartApplication() {
|
||||
// no-op in mock
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function appendTurnActionCopyText(current: string, next: string): string {
|
||||
if (next.trim() === "") return current;
|
||||
if (current.trim() === "") return next;
|
||||
if (current.endsWith("\n") || next.startsWith("\n")) return current + next;
|
||||
return `${current}\n\n${next}`;
|
||||
}
|
||||
@@ -413,6 +413,7 @@ export interface SessionMeta {
|
||||
userId?: string;
|
||||
threadId?: string;
|
||||
sessionSource?: string;
|
||||
recovered?: boolean; // conflict-recovery copy of another session
|
||||
}
|
||||
|
||||
// SessionReference is a session selected via @ past:chats for context injection.
|
||||
@@ -668,6 +669,7 @@ export interface ServerView {
|
||||
tools: number;
|
||||
prompts: number;
|
||||
resources: number;
|
||||
hasTools?: boolean;
|
||||
error?: string;
|
||||
toolList?: MCPToolView[];
|
||||
trustedReadOnlyTools?: string[];
|
||||
|
||||
@@ -953,6 +953,27 @@ function errorMessage(err: unknown): string {
|
||||
return String(err || "");
|
||||
}
|
||||
|
||||
export function effortSwitchNoticeText(err: unknown): 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("already open in another reasonix window") || lower.includes("session lease held")) {
|
||||
return t("status.effortSwitchLeaseHeld");
|
||||
}
|
||||
if (lower.includes("workspace is still starting")) {
|
||||
return t("status.effortSwitchStarting");
|
||||
}
|
||||
if (lower.startsWith("workspace failed to start")) {
|
||||
return t("status.effortSwitchStartupFailed", { err: msg });
|
||||
}
|
||||
if (lower.includes("changed while switching effort") || (lower.includes("tab ") && lower.includes("not found"))) {
|
||||
return t("status.effortSwitchRetry");
|
||||
}
|
||||
return t("status.effortSwitchFailed", { err: msg });
|
||||
}
|
||||
|
||||
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
|
||||
try {
|
||||
dispatchTo(tabId, { type: "meta", meta: await app.MetaForTab(tabId) });
|
||||
@@ -1073,23 +1094,31 @@ export function useController() {
|
||||
if (reset && sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "reset" });
|
||||
|
||||
const stillCurrent = () => sessionLoadCurrent(tabId, seq);
|
||||
const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
|
||||
const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
|
||||
const noteFailure = (label: string, err: unknown) => {
|
||||
addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
|
||||
};
|
||||
|
||||
const historyStartedAt = Date.now();
|
||||
const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1: dispatch fast metadata immediately so the transcript can render
|
||||
// without waiting for slower ancillary calls (e.g. BalanceForTab network).
|
||||
const [meta, historyPage] = await Promise.all([
|
||||
app.MetaForTab(tabId).catch((err) => { noteFailure("meta", err); return undefined; }),
|
||||
skipHistory
|
||||
? Promise.resolve(undefined)
|
||||
: app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS).catch((err) => { noteFailure("history", err); return undefined; }),
|
||||
]);
|
||||
const historyStartedAt = Date.now();
|
||||
const historyPage = skipHistory
|
||||
? undefined
|
||||
: await loadTimed("history", () => app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS));
|
||||
|
||||
if (!stillCurrent()) return;
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
if (!skipHistory && historyPage !== undefined) {
|
||||
const messages = asArray(historyPage.messages);
|
||||
dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" });
|
||||
@@ -1120,21 +1149,20 @@ export function useController() {
|
||||
// in a loading state.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const meta = await loadTimed("meta", () => app.MetaForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
const ancillaryStartedAt = Date.now();
|
||||
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
return loadTimed(`ancillary ${label}`, load);
|
||||
};
|
||||
const [effort, jobs, context] = await Promise.all([
|
||||
loadAncillary("effort", () => app.EffortForTab(tabId)),
|
||||
@@ -1142,22 +1170,30 @@ export function useController() {
|
||||
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
|
||||
]);
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `ancillary ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
|
||||
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
|
||||
app.BalanceForTab(tabId)
|
||||
.then((balance) => {
|
||||
if (sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "balance", balance });
|
||||
if (sessionLoadCurrent(tabId, seq) && stillVisible()) dispatchTo(tabId, { type: "balance", balance });
|
||||
})
|
||||
.catch((err) => { noteFailure("balance", err); });
|
||||
})();
|
||||
@@ -1709,7 +1745,12 @@ export function useController() {
|
||||
|
||||
const setEffort = useCallback(async (level: string) => {
|
||||
if (!activeTabId) return;
|
||||
await app.SetEffortForTab(activeTabId, level).catch(() => {});
|
||||
try {
|
||||
await app.SetEffortForTab(activeTabId, level);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dispatchTo(activeTabId, { type: "meta", meta: await app.MetaForTab(activeTabId) });
|
||||
dispatchTo(activeTabId, { type: "context", context: await app.ContextUsageForTab(activeTabId) });
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* WailsWailsFlags mirrors the `window.wails.flags` object injected by the
|
||||
* Wails v2 runtime (see internal/frontend/runtime/desktop/main.js).
|
||||
*/
|
||||
interface WailsFlags {
|
||||
enableResize: boolean;
|
||||
resizeEdge: string | undefined;
|
||||
borderThickness: number;
|
||||
defaultCursor: string | null;
|
||||
cssDragProperty: string;
|
||||
cssDragValue: string;
|
||||
cssDropProperty: string;
|
||||
cssDropValue: string;
|
||||
shouldDrag: boolean;
|
||||
deferDragToMouseMove: boolean;
|
||||
disableScrollbarDrag: boolean;
|
||||
disableDefaultContextMenu: boolean;
|
||||
enableWailsDragAndDrop: boolean;
|
||||
}
|
||||
|
||||
interface WailsWindow {
|
||||
wails: {
|
||||
flags: WailsFlags;
|
||||
Callback: (msg: string) => void;
|
||||
EventsNotify: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wails?: WailsWindow["wails"];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useWailsResizeFix
|
||||
*
|
||||
* Workaround for a Wails v2.12 frameless-window resize detection bug where
|
||||
* `window.outerWidth/outerHeight` (device-independent pixels) is compared
|
||||
* with `e.clientX/e.clientY` (CSS pixels). When WebView2 ZoomFactor ≠ 1
|
||||
* these coordinate spaces diverge, causing the right/bottom resize zone to
|
||||
* extend far inward or disappear entirely.
|
||||
*
|
||||
* This hook disables the built-in mousemove handler (which uses the wrong
|
||||
* coordinate space) and replaces it with one that uses `window.innerWidth`
|
||||
* and `window.innerHeight` — both in CSS pixels, matching `e.clientX/Y`.
|
||||
*
|
||||
* The Wails mousedown handler still works because it only reads
|
||||
* `window.wails.flags.resizeEdge`, which we continue to set here.
|
||||
*
|
||||
* Upstream fix: https://github.com/wailsapp/wails/issues/4590 (Wails v3
|
||||
* sidestepped by clamping zoom ≥ 1.0). Once Wails v2 ships a proper fix
|
||||
* this hook can be deleted.
|
||||
*
|
||||
* @example
|
||||
* // In App.tsx or any component mounted for the app's lifetime:
|
||||
* useWailsResizeFix(desktopPlatform === "windows");
|
||||
*/
|
||||
export function useWailsResizeFix(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const wails = window.wails;
|
||||
if (!wails) return; // not inside a Wails webview → no-op
|
||||
|
||||
const flags = wails.flags;
|
||||
const bt = flags.borderThickness ?? 6;
|
||||
const previousEnableResize = flags.enableResize;
|
||||
const previousResizeEdge = flags.resizeEdge;
|
||||
const previousCursor = document.documentElement.style.cursor;
|
||||
|
||||
// Restore the default cursor when we're done — memoize the initial value.
|
||||
const defaultCursor = previousCursor;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
// Both operands in CSS pixels — the bug fix.
|
||||
const iw = window.innerWidth;
|
||||
const ih = window.innerHeight;
|
||||
const cx = e.clientX;
|
||||
const cy = e.clientY;
|
||||
|
||||
const right = iw - cx < bt;
|
||||
const left = cx < bt;
|
||||
const top = cy < bt;
|
||||
const bottom = ih - cy < bt;
|
||||
|
||||
let edge: string | undefined;
|
||||
if (right && bottom) edge = "se-resize";
|
||||
else if (left && bottom) edge = "sw-resize";
|
||||
else if (right && top) edge = "ne-resize";
|
||||
else if (left && top) edge = "nw-resize";
|
||||
else if (right) edge = "e-resize";
|
||||
else if (left) edge = "w-resize";
|
||||
else if (top) edge = "n-resize";
|
||||
else if (bottom) edge = "s-resize";
|
||||
|
||||
if (edge !== flags.resizeEdge) {
|
||||
flags.resizeEdge = edge;
|
||||
document.documentElement.style.cursor = edge ?? (defaultCursor || "");
|
||||
}
|
||||
};
|
||||
|
||||
// Disable Wails' built-in mousemove handler (the one that uses outerWidth).
|
||||
flags.enableResize = false;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
flags.enableResize = previousEnableResize;
|
||||
flags.resizeEdge = previousResizeEdge;
|
||||
document.documentElement.style.cursor = previousCursor;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -532,6 +532,12 @@ export const en = {
|
||||
"modelSwitcher.noMatches": "No matching models",
|
||||
"status.noModels": "no switchable models",
|
||||
"status.modelSwitchFailed": "Model switch failed: {err}",
|
||||
"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.tokenModeSwitchFailed": "Token saver switch failed: {err}",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "Reasoning effort",
|
||||
@@ -657,6 +663,29 @@ export const en = {
|
||||
"approval.allowRulePersistent": "Always allow (save)",
|
||||
"approval.allowEditsSession": "Allow edits for session",
|
||||
"approval.allowFilePersistent": "Always allow this file (save)",
|
||||
"approval.allowSandboxEscapeSession": "Use real environment for this session",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "edit file",
|
||||
"approval.toolLabelWriteFile": "write file",
|
||||
"approval.toolLabelMultiEdit": "multi edit",
|
||||
"approval.toolLabelMoveFile": "move file",
|
||||
"approval.toolLabelWebFetch": "web fetch",
|
||||
"approval.toolLabelRunSkill": "run skill",
|
||||
"approval.toolLabelRemember": "remember",
|
||||
"approval.toolLabelForget": "forget",
|
||||
"approval.toolLabelSandboxEscape": "bash sandbox escape",
|
||||
"approval.toolLabelPlanModeReadOnly": "plan-mode read-only command",
|
||||
"approval.memorySaveUpdate": "Save/update memory",
|
||||
"approval.memoryBodyLabel": "body",
|
||||
"approval.memoryArchivePrefix": "Archive memory ",
|
||||
"approval.planModeMcpTrustSubject": "MCP {target} as read-only for planning and research",
|
||||
"approval.planModeMcpTrustReason": "This MCP tool reports read-only, but external read-only hints need your confirmation before plan mode can use them. Choose always allow to remember this trust for future planning and read-only research.",
|
||||
"approval.planModeBashTrustSubject": "Trust {prefix} as a read-only command prefix while planning\nCommand: {command}",
|
||||
"approval.planModeBashTrustReason": "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
"approval.sandboxEscapeSubjectFallback": "run shell command unconfined once",
|
||||
"approval.sandboxEscapeSubjectPrefix": "run unconfined once: ",
|
||||
"approval.sandboxEscapeWrapReason": "Windows sandbox could not wrap this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
|
||||
// ask card
|
||||
"ask.title": "Need your decision",
|
||||
@@ -732,13 +761,13 @@ export const en = {
|
||||
"history.trashActions": "Trash actions",
|
||||
|
||||
// session recovery
|
||||
"recovery.branch": "Recovery branch",
|
||||
"recovery.badge": "Recovery",
|
||||
"recovery.open": "Open recovery branch",
|
||||
"recovery.toast": "Unsaved local changes were saved as {title}.",
|
||||
"recovery.failedLease": "The recovery branch is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The recovery branch could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session was recovered from a save conflict. Compare it with the original before continuing.",
|
||||
"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}",
|
||||
@@ -757,6 +786,7 @@ export const en = {
|
||||
"projectTree.renameProjectWorkbench": "Rename project",
|
||||
"projectTree.projectHistory": "View project history",
|
||||
"projectTree.justNow": "just now",
|
||||
"projectTree.previously": "previously",
|
||||
"projectTree.running": "responding",
|
||||
"projectTree.status.thinking": "thinking",
|
||||
"projectTree.status.streaming": "responding",
|
||||
@@ -1396,7 +1426,7 @@ export const en = {
|
||||
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
|
||||
"settings.addRule": "add {list} rule…",
|
||||
"settings.sandboxTitle": "Sandbox & workspace",
|
||||
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. Use Permissions or approval mode for deploy-style commands.",
|
||||
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. If Bash sandbox is enforced but no OS backend is available, shell commands are refused; switch Bash sandbox off only for trusted workspaces.",
|
||||
"settings.reloadSessionConfig": "Apply manual config changes",
|
||||
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
|
||||
"settings.shellInterpreter": "Shell interpreter",
|
||||
@@ -1405,7 +1435,7 @@ export const en = {
|
||||
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh (PowerShell 7+)",
|
||||
"settings.bashSandbox": "Bash sandbox",
|
||||
"settings.bashEnforce": "enforce (jail bash)",
|
||||
"settings.bashEnforce": "enforce (jail bash; fail closed)",
|
||||
"settings.bashOff": "off (run unconfined)",
|
||||
"settings.allowNetwork": "Allow network egress from sandboxed bash",
|
||||
"settings.workspaceRoot": "Workspace root",
|
||||
@@ -1498,6 +1528,9 @@ export const en = {
|
||||
"settings.textSizeXLarge": "Extra large",
|
||||
"settings.textSizeXXLarge": "Huge",
|
||||
"settings.displayZoom": "Display zoom (restart required)",
|
||||
"settings.displayZoomDecrease": "Decrease display zoom",
|
||||
"settings.displayZoomIncrease": "Increase display zoom",
|
||||
"settings.displayZoomReset": "Reset display zoom to 100%",
|
||||
"settings.fontFamily": "Interface font",
|
||||
"settings.fontFamilySystem": "System",
|
||||
"settings.fontFamilyYaHei": "YaHei",
|
||||
@@ -1558,6 +1591,8 @@ export const en = {
|
||||
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek Official",
|
||||
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
|
||||
@@ -1813,6 +1848,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",
|
||||
@@ -2015,6 +2051,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",
|
||||
|
||||
@@ -386,6 +386,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"modelSwitcher.noMatches": "沒有匹配的模型",
|
||||
"status.noModels": "沒有可切換模型",
|
||||
"status.modelSwitchFailed": "模型切換失敗:{err}",
|
||||
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
|
||||
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
|
||||
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
|
||||
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
|
||||
@@ -509,6 +515,29 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"approval.allowRulePersistent": "總是允許(儲存)",
|
||||
"approval.allowEditsSession": "本會話允許編輯",
|
||||
"approval.allowFilePersistent": "總是允許此檔案(儲存)",
|
||||
"approval.allowSandboxEscapeSession": "本會話使用真實環境",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "編輯檔案",
|
||||
"approval.toolLabelWriteFile": "寫入檔案",
|
||||
"approval.toolLabelMultiEdit": "批次編輯",
|
||||
"approval.toolLabelMoveFile": "移動檔案",
|
||||
"approval.toolLabelWebFetch": "讀取網頁",
|
||||
"approval.toolLabelRunSkill": "執行技能",
|
||||
"approval.toolLabelRemember": "儲存記憶",
|
||||
"approval.toolLabelForget": "刪除記憶",
|
||||
"approval.toolLabelSandboxEscape": "bash 沙箱降級核准",
|
||||
"approval.toolLabelPlanModeReadOnly": "計劃模式唯讀命令",
|
||||
"approval.memorySaveUpdate": "儲存/更新記憶",
|
||||
"approval.memoryBodyLabel": "正文",
|
||||
"approval.memoryArchivePrefix": "封存記憶 ",
|
||||
"approval.planModeMcpTrustSubject": "將 MCP {target} 作為計劃/研究唯讀工具信任",
|
||||
"approval.planModeMcpTrustReason": "這個 MCP 工具宣告自己是唯讀的,但外部唯讀提示需要你確認後,計劃模式才能使用。選擇總是允許可為後續計劃和唯讀研究記住這份信任。",
|
||||
"approval.planModeBashTrustSubject": "在計劃模式中信任 {prefix} 為唯讀命令前綴\n命令:{command}",
|
||||
"approval.planModeBashTrustReason": "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
|
||||
"approval.sandboxEscapeSubjectFallback": "僅本次不進沙箱執行 shell 命令",
|
||||
"approval.sandboxEscapeSubjectPrefix": "僅本次不進沙箱執行:",
|
||||
"approval.sandboxEscapeWrapReason": "Windows 沙箱無法包裝這條命令。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
|
||||
// 提問卡片
|
||||
"ask.title": "需要你決定",
|
||||
@@ -575,13 +604,13 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 會話恢復
|
||||
"recovery.branch": "恢復分支",
|
||||
"recovery.badge": "恢復",
|
||||
"recovery.open": "開啟恢復分支",
|
||||
"recovery.toast": "未儲存的本機內容已儲存為 {title}。",
|
||||
"recovery.failedLease": "恢復分支被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟恢復分支,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話來自儲存衝突恢復。繼續前建議與原會話對比。",
|
||||
"recovery.branch": "衝突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "開啟衝突副本",
|
||||
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
|
||||
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父會話:{parent}",
|
||||
@@ -885,11 +914,11 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
|
||||
"settings.addRule": "新增 {list} 規則…",
|
||||
"settings.sandboxTitle": "沙箱與工作區",
|
||||
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。mvn deploy 這類命令還需要權限規則或核准模式放行。",
|
||||
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。若 Bash 沙箱為 enforce 但宿主機沒有 OS 沙箱後端,shell 命令會被拒絕;只在可信工作區把 Bash 沙箱切到 off。",
|
||||
"settings.reloadSessionConfig": "套用手動設定修改",
|
||||
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔離 bash)",
|
||||
"settings.bashEnforce": "enforce(隔離 bash;失敗即拒絕)",
|
||||
"settings.bashOff": "off(不受限執行)",
|
||||
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
|
||||
"settings.workspaceRoot": "工作區根目錄",
|
||||
@@ -913,6 +942,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.textSizeXXLarge": "超大",
|
||||
"settings.displayZoom": "顯示縮放(需重啟生效)",
|
||||
"settings.displayZoomDecrease": "縮小顯示縮放",
|
||||
"settings.displayZoomIncrease": "放大顯示縮放",
|
||||
"settings.displayZoomReset": "恢復顯示縮放到 100%",
|
||||
"settings.fontFamily": "介面字型",
|
||||
"settings.fontFamilySystem": "系統預設",
|
||||
"settings.fontFamilyYaHei": "微軟正黑體",
|
||||
@@ -967,6 +999,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
|
||||
@@ -1159,6 +1193,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"rewind.disabledNoBoundary": "這條訊息的會話邊界不可用",
|
||||
"rewind.disabledNoCode": "這條訊息之後沒有捕獲到程式碼變更",
|
||||
"rewind.disabledNoEarlier": "這條訊息之前沒有可總結內容",
|
||||
"rewind.disabledNoLater": "這條訊息之後沒有可總結內容",
|
||||
"rewind.filesChanged": "{count} 個檔案",
|
||||
"rewind.turnFiles": "本輪 {count} 個",
|
||||
"rewind.both": "回滾程式碼和對話",
|
||||
@@ -1305,6 +1340,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",
|
||||
@@ -1486,6 +1524,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"clearContext.done": "已清空當前上下文",
|
||||
"clearContext.failed": "清空當前上下文失敗",
|
||||
"projectTree.justNow": "剛剛",
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "輸出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"projectTree.status.streaming": "輸出中",
|
||||
|
||||
@@ -533,6 +533,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"modelSwitcher.noMatches": "没有匹配的模型",
|
||||
"status.noModels": "没有可切换模型",
|
||||
"status.modelSwitchFailed": "模型切换失败:{err}",
|
||||
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
|
||||
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
|
||||
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
|
||||
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切换失败:{err}",
|
||||
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
@@ -658,6 +664,29 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.allowRulePersistent": "总是允许(保存)",
|
||||
"approval.allowEditsSession": "本会话允许编辑",
|
||||
"approval.allowFilePersistent": "总是允许此文件(保存)",
|
||||
"approval.allowSandboxEscapeSession": "本会话使用真实环境",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "编辑文件",
|
||||
"approval.toolLabelWriteFile": "写入文件",
|
||||
"approval.toolLabelMultiEdit": "批量编辑",
|
||||
"approval.toolLabelMoveFile": "移动文件",
|
||||
"approval.toolLabelWebFetch": "读取网页",
|
||||
"approval.toolLabelRunSkill": "运行技能",
|
||||
"approval.toolLabelRemember": "保存记忆",
|
||||
"approval.toolLabelForget": "删除记忆",
|
||||
"approval.toolLabelSandboxEscape": "bash 沙箱降级审批",
|
||||
"approval.toolLabelPlanModeReadOnly": "计划模式只读命令",
|
||||
"approval.memorySaveUpdate": "保存/更新记忆",
|
||||
"approval.memoryBodyLabel": "正文",
|
||||
"approval.memoryArchivePrefix": "归档记忆 ",
|
||||
"approval.planModeMcpTrustSubject": "将 MCP {target} 作为计划/研究只读工具信任",
|
||||
"approval.planModeMcpTrustReason": "这个 MCP 工具声明自己是只读的,但外部只读提示需要你确认后,计划模式才能使用。选择总是允许可为后续计划和只读研究记住这份信任。",
|
||||
"approval.planModeBashTrustSubject": "在计划模式中信任 {prefix} 为只读命令前缀\n命令:{command}",
|
||||
"approval.planModeBashTrustReason": "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
|
||||
"approval.sandboxEscapeSubjectFallback": "仅本次不进沙箱运行 shell 命令",
|
||||
"approval.sandboxEscapeSubjectPrefix": "仅本次不进沙箱运行:",
|
||||
"approval.sandboxEscapeWrapReason": "Windows 沙箱无法包装这条命令。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
|
||||
// 提问卡片
|
||||
"ask.title": "需要你决定",
|
||||
@@ -733,13 +762,13 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 会话恢复
|
||||
"recovery.branch": "恢复分支",
|
||||
"recovery.badge": "恢复",
|
||||
"recovery.open": "打开恢复分支",
|
||||
"recovery.toast": "未保存的本地内容已保存为 {title}。",
|
||||
"recovery.failedLease": "恢复分支被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开恢复分支,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话来自保存冲突恢复。继续前建议与原会话对比。",
|
||||
"recovery.branch": "冲突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "打开冲突副本",
|
||||
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
|
||||
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父会话:{parent}",
|
||||
@@ -758,6 +787,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.renameProjectWorkbench": "重命名项目",
|
||||
"projectTree.projectHistory": "查看项目历史",
|
||||
"projectTree.justNow": "刚刚",
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "输出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"projectTree.status.streaming": "输出中",
|
||||
@@ -1398,7 +1428,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
|
||||
"settings.addRule": "添加 {list} 规则…",
|
||||
"settings.sandboxTitle": "沙箱与工作区",
|
||||
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。mvn deploy 这类命令还需要权限规则或审批模式放行。",
|
||||
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。若 Bash 沙箱为 enforce 但宿主机没有 OS 沙箱后端,shell 命令会被拒绝;只在可信工作区把 Bash 沙箱切到 off。",
|
||||
"settings.reloadSessionConfig": "应用手动配置修改",
|
||||
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
|
||||
"settings.shellInterpreter": "Shell 解释器",
|
||||
@@ -1407,7 +1437,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh(PowerShell 7+)",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔离 bash)",
|
||||
"settings.bashEnforce": "enforce(隔离 bash;失败即拒绝)",
|
||||
"settings.bashOff": "off(不受限运行)",
|
||||
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
|
||||
"settings.workspaceRoot": "工作区根目录",
|
||||
@@ -1500,6 +1530,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.textSizeXXLarge": "超大",
|
||||
"settings.displayZoom": "显示缩放(需重启生效)",
|
||||
"settings.displayZoomDecrease": "缩小显示缩放",
|
||||
"settings.displayZoomIncrease": "放大显示缩放",
|
||||
"settings.displayZoomReset": "恢复显示缩放到 100%",
|
||||
"settings.fontFamily": "界面字体",
|
||||
"settings.fontFamilySystem": "系统默认",
|
||||
"settings.fontFamilyYaHei": "微软雅黑",
|
||||
@@ -1560,6 +1593,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
|
||||
@@ -1815,6 +1850,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
|
||||
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
|
||||
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
|
||||
"rewind.disabledNoLater": "这条消息之后没有可总结内容",
|
||||
"rewind.filesChanged": "{count} 个文件",
|
||||
"rewind.turnFiles": "本轮 {count} 个",
|
||||
"rewind.both": "回滚代码和对话",
|
||||
@@ -2017,6 +2053,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"mock.topicAi": "AI 工程化最佳实践",
|
||||
"mock.topicLab": "临时探索与实验",
|
||||
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
|
||||
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失败,需要确认是否仅本次不进沙箱重跑。",
|
||||
"mock.sandboxEscapeSubject": "仅本次不进沙箱运行:go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
"mock.askQ1Header": "处理方向",
|
||||
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
|
||||
"mock.askQ1Opt1Label": "git stash 后 pull",
|
||||
|
||||
@@ -5207,11 +5207,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
min-width: 0;
|
||||
}
|
||||
.tool__nested-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 34px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
color: color-mix(in srgb, var(--fg-dim) 86%, var(--fg));
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tool__nested-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: inherit;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tool__status-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
@@ -14257,10 +14268,18 @@ body > .mermaid-diagram--fullscreen {
|
||||
.zoom-slider-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-slider__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.zoom-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -14275,14 +14294,67 @@ body > .mermaid-diagram--fullscreen {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 百分比值 — 居中在轨道上方 */
|
||||
/* 百分比值,与右侧步进控件同排显示 */
|
||||
.zoom-slider__value {
|
||||
font-size: var(--font-control-small);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.zoom-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.zoom-stepper__btn,
|
||||
.zoom-stepper__reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 25px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-size: var(--font-control-small);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zoom-stepper__btn {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.zoom-stepper__reset {
|
||||
min-width: 58px;
|
||||
padding: 0 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.zoom-stepper__btn:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.zoom-stepper__btn:not(:disabled):hover,
|
||||
.zoom-stepper__reset:not(:disabled):hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.zoom-stepper__btn:disabled,
|
||||
.zoom-stepper__reset:disabled {
|
||||
color: var(--fg-faint);
|
||||
opacity: .55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 让 label 在 settings-field 网格中靠下对齐 */
|
||||
@@ -14329,40 +14401,17 @@ body > .mermaid-diagram--fullscreen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
width: 26px;
|
||||
height: 16px;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-local-raised);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
background: #fff;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slider-thumb__left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 9px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 9px 0 0 9px;
|
||||
}
|
||||
.slider-thumb__mid {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 0;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
.slider-thumb__right {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 9px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 0 9px 9px 0;
|
||||
}
|
||||
/* slider-thumb is now a single element; child segments were removed. */
|
||||
|
||||
/* 透明 input range — 事件捕获 */
|
||||
.slider-track input[type="range"] {
|
||||
@@ -20475,26 +20524,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
color: color-mix(in srgb, var(--err) 84%, var(--fg));
|
||||
}
|
||||
|
||||
.project-tree__topic--recovered {
|
||||
color: color-mix(in srgb, var(--warn) 18%, var(--fg-dim));
|
||||
}
|
||||
|
||||
.project-tree__topic-recovery {
|
||||
flex: 0 0 auto;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 82%, var(--fg));
|
||||
font-size: 10.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-tree__topic-meta {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@@ -25,6 +25,7 @@ require (
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb h1:au4YwGV3d5zAB6CcboeNocRLwFoE7XGxnx2RYI78iv4=
|
||||
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb/go.mod h1:qL+ewlVGGeJLO97u+7E4K20gLu9oST/lc8FaGo2JJSw=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mainThreadHeartbeatInterval = time.Second
|
||||
mainThreadHangThreshold = 12 * time.Second
|
||||
mainThreadHangCheckInterval = 2 * time.Second
|
||||
mainThreadSleepSkip = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
mainThreadClockBase = time.Now()
|
||||
mainThreadLastHeartbeatElapsed atomic.Int64
|
||||
mainThreadLastHeartbeatWall atomic.Int64
|
||||
mainThreadHangReported atomic.Bool
|
||||
)
|
||||
|
||||
func recordMainThreadHeartbeat(t time.Time) {
|
||||
elapsed := t.Sub(mainThreadClockBase)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
mainThreadLastHeartbeatElapsed.Store(int64(elapsed))
|
||||
mainThreadLastHeartbeatWall.Store(t.UnixNano())
|
||||
}
|
||||
|
||||
func mainThreadHeartbeatAge(now time.Time) (time.Duration, time.Time, bool) {
|
||||
lastElapsed := time.Duration(mainThreadLastHeartbeatElapsed.Load())
|
||||
lastWall := mainThreadLastHeartbeatWall.Load()
|
||||
if lastWall <= 0 {
|
||||
return 0, time.Time{}, false
|
||||
}
|
||||
age := now.Sub(mainThreadClockBase) - lastElapsed
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return age, time.Unix(0, lastWall), true
|
||||
}
|
||||
|
||||
func (a *App) startMainThreadWatchdog() {
|
||||
if !mainThreadWatchdogSupported() {
|
||||
return
|
||||
}
|
||||
a.hangWatchdogMu.Lock()
|
||||
if a.hangWatchdogCancel != nil {
|
||||
a.hangWatchdogMu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.hangWatchdogCancel = cancel
|
||||
mainThreadHangReported.Store(false)
|
||||
recordMainThreadHeartbeat(time.Now())
|
||||
startNativeMainThreadHeartbeat(uint64(mainThreadHeartbeatInterval / time.Millisecond))
|
||||
a.hangWatchdogMu.Unlock()
|
||||
|
||||
a.goSafe("mainThreadHangWatchdog", func() {
|
||||
a.watchMainThreadHeartbeat(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopMainThreadWatchdog() {
|
||||
if !mainThreadWatchdogSupported() {
|
||||
return
|
||||
}
|
||||
a.hangWatchdogMu.Lock()
|
||||
cancel := a.hangWatchdogCancel
|
||||
a.hangWatchdogCancel = nil
|
||||
a.hangWatchdogMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
stopNativeMainThreadHeartbeat()
|
||||
}
|
||||
|
||||
func (a *App) watchMainThreadHeartbeat(ctx context.Context) {
|
||||
ticker := time.NewTicker(mainThreadHangCheckInterval)
|
||||
defer ticker.Stop()
|
||||
lastCheck := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
if now.Sub(lastCheck) > mainThreadSleepSkip {
|
||||
lastCheck = now
|
||||
continue
|
||||
}
|
||||
lastCheck = now
|
||||
age, last, ok := mainThreadHeartbeatAge(now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if age < mainThreadHangThreshold {
|
||||
continue
|
||||
}
|
||||
if mainThreadHangReported.CompareAndSwap(false, true) {
|
||||
a.recordMainThreadHang(age, last, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordMainThreadHang(age time.Duration, lastHeartbeat, observedAt time.Time) {
|
||||
report := mainThreadHangReport(age, lastHeartbeat, observedAt)
|
||||
wrote := writePendingReport(report, false)
|
||||
if m := a.metrics.Load(); m != nil {
|
||||
m.inc("desktop_hang", "main_thread")
|
||||
m.inc("desktop_hang_age", hangAgeBucket(age))
|
||||
m.persist()
|
||||
}
|
||||
slog.Warn("desktop: mac main thread heartbeat stalled",
|
||||
"age", age.Round(time.Millisecond).String(),
|
||||
"lastHeartbeat", lastHeartbeat.Format(time.RFC3339),
|
||||
"pendingReport", wrote,
|
||||
)
|
||||
}
|
||||
|
||||
func mainThreadHangReport(age time.Duration, lastHeartbeat, observedAt time.Time) crashReport {
|
||||
age = age.Round(time.Second)
|
||||
message := fmt.Sprintf(`[mac.main_thread.hang]
|
||||
|
||||
Reasonix detected that the macOS main-thread heartbeat stopped for %s.
|
||||
|
||||
--- watchdog context ---
|
||||
last heartbeat: %s
|
||||
observed at: %s
|
||||
threshold: %s
|
||||
bucket: %s
|
||||
|
||||
--- native runtime context ---
|
||||
%s`,
|
||||
age,
|
||||
lastHeartbeat.UTC().Format(time.RFC3339),
|
||||
observedAt.UTC().Format(time.RFC3339),
|
||||
mainThreadHangThreshold,
|
||||
hangAgeBucket(age),
|
||||
nativeResourceContext(),
|
||||
)
|
||||
report := baseCrashReport("performance")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.ErrorType = "MacMainThreadHang"
|
||||
report.ErrorMessage = sanitizeCrashText("macOS main thread heartbeat stopped; AppKit/Wails run loop may be blocked.", maxCrashFieldBytes)
|
||||
report.TopFrame = "mac.main_thread.heartbeat"
|
||||
report.OccurredAt = observedAt.UTC().Format(time.RFC3339)
|
||||
report.Message = sanitizeCrashText(message, maxCrashDetailBytes)
|
||||
return report
|
||||
}
|
||||
|
||||
func hangAgeBucket(age time.Duration) string {
|
||||
seconds := age.Seconds()
|
||||
switch {
|
||||
case seconds < 15:
|
||||
return "s_10_15"
|
||||
case seconds < 30:
|
||||
return "s_15_30"
|
||||
case seconds < 60:
|
||||
return "s_30_60"
|
||||
case seconds < 300:
|
||||
return "m_1_5"
|
||||
default:
|
||||
return "m_5_plus"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
#include <dispatch/dispatch.h>
|
||||
|
||||
extern void reasonixDesktopMainHeartbeat(void);
|
||||
|
||||
static dispatch_source_t reasonix_main_heartbeat_timer;
|
||||
|
||||
static void reasonix_main_heartbeat_handler(void *ctx) {
|
||||
reasonixDesktopMainHeartbeat();
|
||||
}
|
||||
|
||||
static void reasonix_start_main_heartbeat(uint64_t interval_ms) {
|
||||
if (reasonix_main_heartbeat_timer != NULL) {
|
||||
return;
|
||||
}
|
||||
reasonix_main_heartbeat_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
|
||||
dispatch_set_context(reasonix_main_heartbeat_timer, NULL);
|
||||
dispatch_source_set_event_handler_f(reasonix_main_heartbeat_timer, reasonix_main_heartbeat_handler);
|
||||
dispatch_source_set_timer(reasonix_main_heartbeat_timer, dispatch_time(DISPATCH_TIME_NOW, 0), interval_ms * NSEC_PER_MSEC, 100 * NSEC_PER_MSEC);
|
||||
dispatch_resume(reasonix_main_heartbeat_timer);
|
||||
}
|
||||
|
||||
static void reasonix_stop_main_heartbeat(void) {
|
||||
if (reasonix_main_heartbeat_timer == NULL) {
|
||||
return;
|
||||
}
|
||||
dispatch_source_cancel(reasonix_main_heartbeat_timer);
|
||||
reasonix_main_heartbeat_timer = NULL;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "time"
|
||||
|
||||
func mainThreadWatchdogSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func startNativeMainThreadHeartbeat(intervalMS uint64) {
|
||||
C.reasonix_start_main_heartbeat(C.uint64_t(intervalMS))
|
||||
}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {
|
||||
C.reasonix_stop_main_heartbeat()
|
||||
}
|
||||
|
||||
//export reasonixDesktopMainHeartbeat
|
||||
func reasonixDesktopMainHeartbeat() {
|
||||
recordMainThreadHeartbeat(time.Now())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
func mainThreadWatchdogSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func startNativeMainThreadHeartbeat(uint64) {}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestHangAgeBucket(t *testing.T) {
|
||||
cases := []struct {
|
||||
age time.Duration
|
||||
want string
|
||||
}{
|
||||
{12 * time.Second, "s_10_15"},
|
||||
{16 * time.Second, "s_15_30"},
|
||||
{45 * time.Second, "s_30_60"},
|
||||
{2 * time.Minute, "m_1_5"},
|
||||
{10 * time.Minute, "m_5_plus"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hangAgeBucket(c.age); got != c.want {
|
||||
t.Errorf("hangAgeBucket(%s) = %q, want %q", c.age, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainThreadHangReportIsStructuredPerformanceReport(t *testing.T) {
|
||||
last := time.Date(2026, 7, 4, 9, 19, 11, 0, time.UTC)
|
||||
observed := last.Add(16 * time.Second)
|
||||
|
||||
r := mainThreadHangReport(16*time.Second, last, observed)
|
||||
|
||||
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
|
||||
t.Fatalf("unexpected report identity: %+v", r)
|
||||
}
|
||||
if r.SchemaVersion != 2 || r.ErrorType != "MacMainThreadHang" || r.TopFrame == "" || r.OccurredAt == "" {
|
||||
t.Fatalf("structured fields missing: %+v", r)
|
||||
}
|
||||
for _, want := range []string{"Reasonix detected", "last heartbeat:", "bucket: s_15_30", "goroutines:"} {
|
||||
if !strings.Contains(r.Message, want) {
|
||||
t.Fatalf("hang report message missing %q:\n%s", want, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainThreadHeartbeatAgeIgnoresWallClockJump(t *testing.T) {
|
||||
oldBase := mainThreadClockBase
|
||||
oldElapsed := mainThreadLastHeartbeatElapsed.Load()
|
||||
oldWall := mainThreadLastHeartbeatWall.Load()
|
||||
t.Cleanup(func() {
|
||||
mainThreadClockBase = oldBase
|
||||
mainThreadLastHeartbeatElapsed.Store(oldElapsed)
|
||||
mainThreadLastHeartbeatWall.Store(oldWall)
|
||||
})
|
||||
|
||||
base := time.Now()
|
||||
mainThreadClockBase = base
|
||||
mainThreadLastHeartbeatElapsed.Store(int64(time.Second))
|
||||
mainThreadLastHeartbeatWall.Store(base.Add(-time.Hour).UnixNano())
|
||||
|
||||
age, _, ok := mainThreadHeartbeatAge(base.Add(2 * time.Second))
|
||||
if !ok {
|
||||
t.Fatal("expected heartbeat age")
|
||||
}
|
||||
if age != time.Second {
|
||||
t.Fatalf("age = %s, want monotonic elapsed 1s despite wall-clock jump", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMainThreadHangWritesPendingReportAndMetrics(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
os.Remove(pendingCrashPath())
|
||||
os.Remove(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
|
||||
})
|
||||
app := NewApp()
|
||||
app.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
||||
|
||||
last := time.Now().Add(-20 * time.Second)
|
||||
app.recordMainThreadHang(20*time.Second, last, time.Now())
|
||||
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending hang report")
|
||||
}
|
||||
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
|
||||
t.Fatalf("pending report = %+v", r)
|
||||
}
|
||||
c := readCounters(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
|
||||
if got := c["desktop_hang"]["main_thread"]; got != 1 {
|
||||
t.Fatalf("desktop_hang/main_thread = %d, want 1", got)
|
||||
}
|
||||
if got := c["desktop_hang_age"]["s_15_30"]; got != 1 {
|
||||
t.Fatalf("desktop_hang_age/s_15_30 = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,9 @@ func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, e
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
if rebuildErr := a.rebuild(); rebuildErr != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
|
||||
return out, nil
|
||||
}
|
||||
return out, rebuildErr
|
||||
}
|
||||
return out, nil
|
||||
@@ -161,7 +164,13 @@ func (a *App) RemovePlugin(name string) error {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
return a.rebuild()
|
||||
if err := a.rebuild(); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
@@ -172,7 +181,13 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
return a.rebuild()
|
||||
if err := a.rebuild(); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) UpdatePlugin(name string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
|
||||
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
|
||||
// any residual raw access to tab.sessionLease shows up as a data race, and the
|
||||
// final acquire asserts no lease leaked through an interleaving.
|
||||
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "lease-helper-hammer.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
tabA := &WorkspaceTab{ID: "a"}
|
||||
tabB := &WorkspaceTab{ID: "b"}
|
||||
|
||||
const iterations = 300
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
_ = tabA.ensureSessionLease(path)
|
||||
_ = tabA.sessionLeaseRuntimeKey()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
// The applyRuntimeTab transfer shape: move A's lease to B and back.
|
||||
tabB.adoptSessionLease(tabA.takeSessionLease())
|
||||
tabA.adoptSessionLease(tabB.takeSessionLease())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
tabA.releaseSessionLease()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
tabA.releaseSessionLease()
|
||||
tabB.releaseSessionLease()
|
||||
lease, err := agent.TryAcquireSessionLease(key)
|
||||
if err != nil {
|
||||
t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
|
||||
// lease ownership through the locked helpers and the visible tab keeps none.
|
||||
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "detach-transfer.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tab.releaseSessionLease()
|
||||
for _, d := range app.detachedSessions {
|
||||
d.releaseSessionLease()
|
||||
}
|
||||
})
|
||||
|
||||
if !app.detachRuntimeForReplacement(tab) {
|
||||
t.Fatal("detachRuntimeForReplacement failed for a live tab")
|
||||
}
|
||||
detached := app.detachedSessions[key]
|
||||
if detached == nil {
|
||||
t.Fatal("detached runtime was not registered")
|
||||
}
|
||||
if got := detached.sessionLeaseRuntimeKey(); got != key {
|
||||
t.Fatalf("detached clone lease key = %q, want %q", got, key)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != "" {
|
||||
t.Fatalf("visible tab still holds lease key %q after detach", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
|
||||
// CloseTab already unlinked must not be re-published into detachedSessions
|
||||
// (the "session resurrects" class, #4384).
|
||||
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "detach-removed.jsonl")
|
||||
|
||||
removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
|
||||
if app.detachRuntimeForReplacement(removed) {
|
||||
t.Fatal("removed tab was detached into the background registry")
|
||||
}
|
||||
if len(app.detachedSessions) != 0 {
|
||||
t.Fatal("removed tab left an entry in detachedSessions")
|
||||
}
|
||||
|
||||
orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
|
||||
if app.detachRuntimeForReplacement(orphan) {
|
||||
t.Fatal("tab absent from a.tabs was detached into the background registry")
|
||||
}
|
||||
if len(app.detachedSessions) != 0 {
|
||||
t.Fatal("orphan tab left an entry in detachedSessions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
|
||||
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
|
||||
// -race; before setBinding existed the tabID write raced every Emit.
|
||||
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
|
||||
sink := &tabEventSink{tabID: "before"}
|
||||
const iterations = 500
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
|
||||
sink.clearContext()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
if tabID, _ := sink.binding(); tabID == "" {
|
||||
t.Fatal("sink lost its tab binding")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
|
||||
// buildGeneration to strand any in-flight async build, so its swap (and every
|
||||
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
|
||||
// and rely on runtimeRebuildMu instead.
|
||||
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration = 3
|
||||
app.mu.Unlock()
|
||||
if app.tabBuildSuperseded(tab, 3) {
|
||||
t.Fatal("build with the current generation must not be superseded")
|
||||
}
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration++ // the rebind-side invalidation
|
||||
app.mu.Unlock()
|
||||
if !app.tabBuildSuperseded(tab, 3) {
|
||||
t.Fatal("stale-generation build must be superseded after a rebind bump")
|
||||
}
|
||||
if app.tabBuildSuperseded(tab, 0) {
|
||||
t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
|
||||
}
|
||||
app.mu.Lock()
|
||||
tab.removed = true
|
||||
app.mu.Unlock()
|
||||
if !app.tabBuildSuperseded(tab, 0) {
|
||||
t.Fatal("removed tab must supersede every build, including synchronous ones")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
|
||||
// release the lease bound to their own path; a mismatched key (the rebind's
|
||||
// replacement session) must be left untouched.
|
||||
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
pathA := filepath.Join(dir, "lease-key-a.jsonl")
|
||||
pathB := filepath.Join(dir, "lease-key-b.jsonl")
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
if err := tab.ensureSessionLease(pathB); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
|
||||
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
|
||||
if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
|
||||
t.Fatal("mismatched key released the tab's live lease")
|
||||
}
|
||||
|
||||
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
|
||||
if err != nil {
|
||||
t.Fatalf("matching key did not release the lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
|
||||
// interleaving from the #5968 review: a stale async build cleans up after a
|
||||
// rebind's replacement build already published its own SharedHostKey and
|
||||
// session lease on the same live tab. The stale build must drop only its own
|
||||
// host reference and lease, never the tab's.
|
||||
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "superseded-old.jsonl")
|
||||
newPath := filepath.Join(dir, "superseded-new.jsonl")
|
||||
newKey := sessionRuntimeKey(newPath)
|
||||
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}}
|
||||
// The stale build and the replacement build each hold one reference on
|
||||
// the same workspace-root host (the common same-root rebind).
|
||||
app.acquireSharedHost("root")
|
||||
app.acquireSharedHost("root")
|
||||
|
||||
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
|
||||
app.tabs["tab"] = tab
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
if err := tab.ensureSessionLease(newPath); err != nil {
|
||||
t.Fatalf("ensureSessionLease(new): %v", err)
|
||||
}
|
||||
|
||||
// Stale build abandons with ITS key material: the old lease path and the
|
||||
// root key it acquired.
|
||||
app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
|
||||
|
||||
app.mu.RLock()
|
||||
hostKey := tab.SharedHostKey
|
||||
app.mu.RUnlock()
|
||||
if hostKey != "root" {
|
||||
t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
|
||||
}
|
||||
app.sharedHostsMu.Lock()
|
||||
entry := app.sharedHosts["root"]
|
||||
refs := 0
|
||||
if entry != nil {
|
||||
refs = entry.refs
|
||||
}
|
||||
app.sharedHostsMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
|
||||
t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
|
||||
}
|
||||
|
||||
// Removal shape: the abandoned build's own key still on the tab is the
|
||||
// last reference and must be released.
|
||||
app.abandonSupersededBuild(tab, nil, "", newKey)
|
||||
lease, err := agent.TryAcquireSessionLease(newKey)
|
||||
if err != nil {
|
||||
t.Fatalf("matching-key abandon did not release the lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
|
||||
// review scenario: an async startTabControllerBuild is stalled right after
|
||||
// binding its session lease (holding its controller, lease, and shared-host
|
||||
// reference), a rebind to a different session starts meanwhile, and the
|
||||
// stalled build then tries to finish. The rebind must bump the generation
|
||||
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
|
||||
// superseded branches: the rebound controller must survive un-overwritten,
|
||||
// the stale build's lease and host reference must be released, and the
|
||||
// replacement build's ownership must stay intact.
|
||||
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
|
||||
newPath := filepath.Join(dir, "rebind-race-new.jsonl")
|
||||
writeHistoryTestSession(t, oldPath, "old prompt")
|
||||
writeHistoryTestSession(t, newPath, "new prompt")
|
||||
oldKey := sessionRuntimeKey(oldPath)
|
||||
newKey := sessionRuntimeKey(newPath)
|
||||
|
||||
app := NewApp()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: oldPath,
|
||||
sink: &tabEventSink{tabID: "tab", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
app.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
app.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
// Stall the async build inside its lease bind: at that point it already
|
||||
// holds its controller, its lease, and its shared-host reference.
|
||||
stalled := make(chan struct{})
|
||||
releaseHook := make(chan struct{})
|
||||
var once sync.Once
|
||||
sessionLeaseAcquireHookForTest = func() {
|
||||
once.Do(func() {
|
||||
close(stalled)
|
||||
<-releaseHook
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-releaseHook:
|
||||
default:
|
||||
close(releaseHook)
|
||||
}
|
||||
})
|
||||
|
||||
// startTabControllerBuild only backgrounds the build when a Wails context
|
||||
// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
|
||||
// nil-ctx emit guards are what every other build test relies on too).
|
||||
buildCtx, cancel := context.WithCancel(context.Background())
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
tab.buildCancel = cancel
|
||||
app.mu.Unlock()
|
||||
buildDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(buildDone)
|
||||
app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
}()
|
||||
select {
|
||||
case <-stalled:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("async build did not reach the lease bind")
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession(new): %v", err)
|
||||
}
|
||||
rebindErr := make(chan error, 1)
|
||||
go func() {
|
||||
rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
|
||||
}()
|
||||
|
||||
// Wait until the rebind's validation section has invalidated the async
|
||||
// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
|
||||
// while the async build is still stalled pre-swap.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
app.mu.RLock()
|
||||
generation := tab.buildGeneration
|
||||
app.mu.RUnlock()
|
||||
if generation >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("rebind did not bump the build generation")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
close(releaseHook)
|
||||
select {
|
||||
case err := <-rebindErr:
|
||||
if err != nil {
|
||||
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("rebind did not finish after releasing the stalled build")
|
||||
}
|
||||
select {
|
||||
case <-buildDone:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("stale async build did not finish its superseded cleanup")
|
||||
}
|
||||
|
||||
app.mu.RLock()
|
||||
builtCtrl := tab.Ctrl
|
||||
sessionPath := tab.SessionPath
|
||||
app.mu.RUnlock()
|
||||
if builtCtrl == nil {
|
||||
t.Fatal("rebind left the tab without a controller")
|
||||
}
|
||||
if got := builtCtrl.SessionPath(); got != newPath {
|
||||
t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
|
||||
}
|
||||
if sessionPath != newPath {
|
||||
t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
|
||||
t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
|
||||
}
|
||||
// The stale build's lease must be gone (released by its superseded
|
||||
// cleanup or replaced by the rebound build's adopt) — the old session
|
||||
// must be acquirable again.
|
||||
lease, err := agent.TryAcquireSessionLease(oldKey)
|
||||
if err != nil {
|
||||
t.Fatalf("stale build leaked its session lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
// Exactly one shared-host reference may remain: the rebound build's. The
|
||||
// stale build must have released its own.
|
||||
app.sharedHostsMu.Lock()
|
||||
refs := 0
|
||||
for _, entry := range app.sharedHosts {
|
||||
refs += entry.refs
|
||||
}
|
||||
app.sharedHostsMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
|
||||
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
|
||||
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
|
||||
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"tab": tab},
|
||||
tabOrder: []string{"tab"},
|
||||
activeTabID: "tab",
|
||||
}
|
||||
|
||||
const iterations = 100
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < iterations; i++ {
|
||||
app.mu.Lock()
|
||||
tab.Ready = !tab.Ready
|
||||
tab.Label = fmt.Sprintf("model-%d", i)
|
||||
tab.StartupErr = ""
|
||||
tab.model = fmt.Sprintf("provider/m%d", i)
|
||||
tab.goal = fmt.Sprintf("goal-%d", i)
|
||||
tab.tokenMode = "full"
|
||||
app.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < iterations; i++ {
|
||||
meta := app.MetaForTab("tab")
|
||||
if meta.EventChannel == "" {
|
||||
t.Fatal("MetaForTab returned zero meta for a live tab")
|
||||
}
|
||||
}
|
||||
<-done
|
||||
}
|
||||
@@ -34,6 +34,10 @@ func (t desktopCountingTool) Execute(context.Context, json.RawMessage) (string,
|
||||
}
|
||||
|
||||
func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping desktop E2E repeat-guard test in short mode")
|
||||
}
|
||||
|
||||
var calls int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func sessionWithFreshSystemPrompt(session *agent.Session, system string) *agent.
|
||||
if systemPromptFrom(messages) == "" {
|
||||
return session
|
||||
}
|
||||
return &agent.Session{Messages: withFreshSystemPrompt(messages, system)}
|
||||
return session.CloneWithMessages(withFreshSystemPrompt(messages, system))
|
||||
}
|
||||
|
||||
func resumeWithFreshSystemPrompt(ctrl interface {
|
||||
@@ -55,7 +55,16 @@ func resumeWithFreshSystemPrompt(ctrl interface {
|
||||
return
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
ctrl.Resume(&agent.Session{Messages: withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))}, path)
|
||||
next := withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))
|
||||
if path != "" {
|
||||
if loaded, err := agent.LoadSession(path); err == nil && loaded != nil {
|
||||
if resumed, ok := loaded.CloneWithMessagesIfCompatible(next); ok {
|
||||
ctrl.Resume(resumed, path)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ctrl.Resume(agent.NewSession("").CloneWithMessages(next), path)
|
||||
return
|
||||
}
|
||||
if path != "" {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
type promptResumeCtrl struct {
|
||||
history []provider.Message
|
||||
resumed *agent.Session
|
||||
path string
|
||||
}
|
||||
|
||||
func (c *promptResumeCtrl) History() []provider.Message {
|
||||
return append([]provider.Message(nil), c.history...)
|
||||
}
|
||||
|
||||
func (c *promptResumeCtrl) Resume(s *agent.Session, path string) {
|
||||
c.resumed = s
|
||||
c.path = path
|
||||
}
|
||||
|
||||
func (c *promptResumeCtrl) SetSessionPath(path string) {
|
||||
c.path = path
|
||||
}
|
||||
|
||||
func TestSessionWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := agent.NewSession("old sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save base: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
resumed := sessionWithFreshSystemPrompt(loaded, "new sys")
|
||||
msgs := resumed.Snapshot()
|
||||
msgs[3].Content = "[elided tool result]"
|
||||
resumed.Replace(msgs)
|
||||
if err := resumed.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite fresh-system resume: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession rewritten: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[0].Content; got != "new sys" {
|
||||
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
|
||||
}
|
||||
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
|
||||
t.Fatalf("tool result after rewrite = %q, want elided", got)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after owned resume rewrite = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := agent.NewSession("old sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save base: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
|
||||
resumeWithFreshSystemPrompt(ctrl, loaded.Snapshot(), path)
|
||||
if ctrl.resumed == nil {
|
||||
t.Fatalf("Resume was not called")
|
||||
}
|
||||
|
||||
msgs := ctrl.resumed.Snapshot()
|
||||
msgs[3].Content = "[elided tool result]"
|
||||
ctrl.resumed.Replace(msgs)
|
||||
if err := ctrl.resumed.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite resumed history: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.path; got != path {
|
||||
t.Fatalf("resume path = %q, want %q", got, path)
|
||||
}
|
||||
reloaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession rewritten: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[0].Content; got != "new sys" {
|
||||
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
|
||||
}
|
||||
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
|
||||
t.Fatalf("tool result after rewrite = %q, want elided", got)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after resume rewrite = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeWithFreshSystemPromptRejectsStaleCarriedHistoryBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
current := agent.NewSession("old 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"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk two"})
|
||||
if err := current.Save(path); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
stale := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "old sys"},
|
||||
{Role: provider.RoleUser, Content: "first"},
|
||||
{Role: provider.RoleAssistant, Content: "one"},
|
||||
}
|
||||
ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
|
||||
resumeWithFreshSystemPrompt(ctrl, stale, path)
|
||||
if ctrl.resumed == nil {
|
||||
t.Fatalf("Resume was not called")
|
||||
}
|
||||
if err := ctrl.resumed.SaveRewrite(path); !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("SaveRewrite stale carried history err = %v, want ErrSessionSnapshotConflict", err)
|
||||
}
|
||||
|
||||
reloaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession current: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "disk two" {
|
||||
t.Fatalf("original tail after stale resume rewrite = %q, want disk two", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
func saveSnapshotTurns(t *testing.T, path string, turns int) *agent.Session {
|
||||
t.Helper()
|
||||
s := agent.NewSession("sys")
|
||||
for i := 0; i < turns; i++ {
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "prompt " + string(rune('a'+i))})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot turn %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestMergeSessionInfosCountsRecoveryActivity(t *testing.T) {
|
||||
summaries := map[string]topicSummary{}
|
||||
now := time.Now()
|
||||
infos := []agent.SessionInfo{
|
||||
{
|
||||
Path: "/tmp/original.jsonl",
|
||||
Turns: 3,
|
||||
LastActivityAt: now.Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: "topic-1",
|
||||
},
|
||||
{
|
||||
Path: "/tmp/original-recovery-abc.jsonl",
|
||||
Turns: 5,
|
||||
LastActivityAt: now,
|
||||
Scope: "global",
|
||||
TopicID: "topic-1",
|
||||
Recovered: true,
|
||||
},
|
||||
}
|
||||
mergeSessionInfos("/tmp", infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries)
|
||||
summary := summaries[topicSummaryKey("global", "", "topic-1")]
|
||||
if !summary.hasNormalSession || !summary.hasRecoveryOnly {
|
||||
t.Fatalf("summary flags = %+v, want both normal and recovery seen", summary)
|
||||
}
|
||||
if summary.turns != 3 {
|
||||
t.Fatalf("turns = %d, want 3 (recovery copies must not double-count)", summary.turns)
|
||||
}
|
||||
// The copy is the live transcript after recovery: its newer activity must
|
||||
// drive topic recency, unread state, and time filters.
|
||||
if summary.lastActivityAt != now.UnixMilli() {
|
||||
t.Fatalf("lastActivityAt = %d, want recovery activity %d", summary.lastActivityAt, now.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicHiddenAsRecoveryOnly(t *testing.T) {
|
||||
recoveryOnly := topicSummary{hasRecoveryOnly: true}
|
||||
cases := []struct {
|
||||
name string
|
||||
summary topicSummary
|
||||
pinned bool
|
||||
sessions []runtimeSessionStatus
|
||||
want bool
|
||||
}{
|
||||
{"recovery-only idle", recoveryOnly, false, nil, true},
|
||||
{"normal session present", topicSummary{hasRecoveryOnly: true, hasNormalSession: true}, false, nil, false},
|
||||
{"pinned stays visible", recoveryOnly, true, nil, false},
|
||||
{"single open runtime", recoveryOnly, false, []runtimeSessionStatus{{open: true}}, false},
|
||||
// topicRuntimeStatus reports open/running only for single-session
|
||||
// topics; the hide rule must still see a two-session topic as live.
|
||||
{"two runtime sessions one open", recoveryOnly, false, []runtimeSessionStatus{{open: true}, {running: false}}, false},
|
||||
{"detached running runtime", recoveryOnly, false, []runtimeSessionStatus{{running: true}, {}}, false},
|
||||
{"idle runtime entries only", recoveryOnly, false, []runtimeSessionStatus{{}, {}}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := topicHiddenAsRecoveryOnly(c.summary, c.pinned, c.sessions); got != c.want {
|
||||
t.Errorf("%s: hidden = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashSessionMatchesLiveSeesEventLogDivergence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
live := filepath.Join(dir, "session.jsonl")
|
||||
s := saveSnapshotTurns(t, live, 1)
|
||||
|
||||
// Simulate an old trash copy taken at checkpoint time: same anchor bytes,
|
||||
// same event log state.
|
||||
trashDir := filepath.Join(dir, "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trashPath := filepath.Join(trashDir, "session.jsonl")
|
||||
for _, pair := range [][2]string{
|
||||
{live, trashPath},
|
||||
{store.SessionEventLog(live), store.SessionEventLog(trashPath)},
|
||||
} {
|
||||
b, err := os.ReadFile(pair[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pair[1], b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
same, err := trashSessionMatchesLive(live, trashPath)
|
||||
if err != nil {
|
||||
t.Fatalf("trashSessionMatchesLive identical: %v", err)
|
||||
}
|
||||
if !same {
|
||||
t.Fatal("identical live/trash reported as different")
|
||||
}
|
||||
|
||||
// The live session keeps chatting: growth lands in the event log only, so
|
||||
// the two .jsonl checkpoints stay byte-identical. Byte comparison would
|
||||
// call this a duplicate and delete the live session's newer history.
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "newer work"})
|
||||
if err := s.SaveSnapshot(live); err != nil {
|
||||
t.Fatalf("SaveSnapshot diverge: %v", err)
|
||||
}
|
||||
liveAnchor, _ := os.ReadFile(live)
|
||||
trashAnchor, _ := os.ReadFile(trashPath)
|
||||
if string(liveAnchor) != string(trashAnchor) {
|
||||
t.Skip("checkpoints diverged on disk; byte-compare trap not reproducible here")
|
||||
}
|
||||
same, err = trashSessionMatchesLive(live, trashPath)
|
||||
if err != nil {
|
||||
t.Fatalf("trashSessionMatchesLive diverged: %v", err)
|
||||
}
|
||||
if same {
|
||||
t.Fatal("live session with newer event log reported as duplicate of trash copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashPathsBlockedWhileLeaseHeld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
saveSnapshotTurns(t, path, 1)
|
||||
|
||||
// A live owner (any runtime — this process or another) holds the lease
|
||||
// lock on an open handle for its whole hold. Every destructive path must
|
||||
// refuse while it is held: probing once and deleting later would let the
|
||||
// owner's freshly locked lease file be unlinked out from under it.
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
lease.Release()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("trashSessionArtifactsBeforeMove err = %v, want errSessionBusyElsewhere", err)
|
||||
}
|
||||
if err := reconcileDesktopTrashSessionArtifacts(dir, path, "session.jsonl"); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("reconcileDesktopTrashSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
|
||||
}
|
||||
if err := removeDesktopSessionArtifacts(path); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("removeDesktopSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("session file touched despite live owner: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(path)); err != nil {
|
||||
t.Fatalf("event log touched despite live owner: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionLeaseLock(path)); err != nil {
|
||||
t.Fatalf("lease lock deleted while held: %v", err)
|
||||
}
|
||||
|
||||
// Once the owner releases, the same trash call succeeds and the lock
|
||||
// sidecars are gone with it.
|
||||
lease.Release()
|
||||
released = true
|
||||
if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); err != nil {
|
||||
t.Fatalf("trashSessionArtifactsBeforeMove after release: %v", err)
|
||||
}
|
||||
for _, p := range []string{
|
||||
path,
|
||||
store.SessionLockFile(path),
|
||||
store.SessionLeaseLock(path),
|
||||
store.SessionLeaseInfo(path),
|
||||
} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("artifact survived trash: %s (err=%v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptHistorySeesEventLogPrompts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
s := agent.NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first prompt"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot: %v", err)
|
||||
}
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "second prompt"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot append: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := collectPromptHistoryEntries(path, info, func(s string) string { return s })
|
||||
if err != nil {
|
||||
t.Fatalf("collectPromptHistoryEntries: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("prompt history entries = %d, want 2 (event-log prompts must appear)", len(entries))
|
||||
}
|
||||
if entries[0].Text != "first prompt" || entries[1].Text != "second prompt" {
|
||||
t.Fatalf("prompt history texts = %q, %q", entries[0].Text, entries[1].Text)
|
||||
}
|
||||
if entries[1].At == 0 {
|
||||
t.Fatal("appended prompt lost its timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicTitleUserTurnsSeesEventLogTurns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
saveSnapshotTurns(t, path, 3)
|
||||
|
||||
users := topicTitleUserTurnsFromSession(path)
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("user turns = %d, want 3 (≥3-turn title upgrade depends on this)", len(users))
|
||||
}
|
||||
}
|
||||
+282
-42
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -144,12 +145,36 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
|
||||
{src: sessionPath, name: key},
|
||||
{src: store.SessionMeta(sessionPath), name: key + ".meta"},
|
||||
{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: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
|
||||
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
|
||||
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
|
||||
}
|
||||
}
|
||||
|
||||
// errSessionBusyElsewhere is the sanitized error surfaced when a destructive
|
||||
// session operation is blocked by a live owner. It intentionally carries no
|
||||
// writer id, hostname, or path.
|
||||
var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process")
|
||||
|
||||
// acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with
|
||||
// the sanitized busy error. The guard holds the session's save and lease
|
||||
// locks across the destructive operation and deletes the lock files
|
||||
// atomically with the release — a one-shot busy probe followed by RemoveAll
|
||||
// would let another process acquire the lease in between and then lose its
|
||||
// freshly locked lease file, breaking cross-process mutual exclusion.
|
||||
func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) {
|
||||
guard, err := agent.TryAcquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return nil, errSessionBusyElsewhere
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return guard, nil
|
||||
}
|
||||
|
||||
func sessionOwnedArtifactPaths(sessionPath string) []string {
|
||||
key := filepath.Base(sessionPath)
|
||||
artifacts := sessionTrashArtifacts(sessionPath, key)
|
||||
@@ -180,8 +205,39 @@ func reconcileDesktopCleanupPending(dir string) error {
|
||||
}
|
||||
|
||||
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
// Hold the removal guard across the whole move so no runtime can acquire
|
||||
// the session (or save into it) while its artifacts are relocated; the
|
||||
// lock sidecars are deleted atomically with the guard release.
|
||||
guard, err := acquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
@@ -192,6 +248,9 @@ func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
@@ -216,14 +275,17 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
discardable, err := liveSessionDiscardable(sessionPath)
|
||||
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discardable {
|
||||
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
|
||||
}
|
||||
@@ -234,37 +296,87 @@ 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() {
|
||||
discardable, err := liveSessionDiscardable(sessionPath)
|
||||
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return preparedSessionTrashTarget{}, err
|
||||
}
|
||||
if discardable {
|
||||
return false, removeDesktopSessionArtifacts(sessionPath)
|
||||
if removable {
|
||||
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 preparedSessionTrashTarget{}, err
|
||||
}
|
||||
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
|
||||
// be removed even though a trash copy already exists under the same key: the
|
||||
// live file must be discardable (empty stub) or byte-identical to the trash
|
||||
// copy, and no other runtime may hold its session lease — another process could
|
||||
// be mid-write, and removing the file would silently drop its next save.
|
||||
func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) {
|
||||
discardable, err := liveSessionDiscardable(sessionPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
duplicate := false
|
||||
if !discardable {
|
||||
duplicate, err = trashSessionMatchesLive(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if !discardable && !duplicate {
|
||||
return false, nil
|
||||
}
|
||||
return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil
|
||||
}
|
||||
|
||||
func liveSessionDiscardable(sessionPath string) (bool, error) {
|
||||
@@ -291,6 +403,20 @@ func liveSessionDiscardable(sessionPath string) (bool, error) {
|
||||
return !session.HasContent(), nil
|
||||
}
|
||||
|
||||
func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) {
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
// Compare decoded transcripts, not .jsonl bytes: the checkpoint only
|
||||
// changes at checkpoints, so two byte-identical .jsonl files can hide
|
||||
// diverged event logs — and treating them as duplicates would delete the
|
||||
// live session's newer history.
|
||||
return agent.SessionsShareContent(sessionPath, trashPath)
|
||||
}
|
||||
|
||||
func sessionFileHasConversationContent(sessionPath string) bool {
|
||||
if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) {
|
||||
return false
|
||||
@@ -310,15 +436,27 @@ 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
|
||||
}
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
|
||||
// there takes its own removal guard, and the guard is not reentrant.
|
||||
guard, err := acquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer guard.Release()
|
||||
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 {
|
||||
@@ -332,6 +470,9 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
|
||||
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
@@ -360,17 +501,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
|
||||
@@ -443,11 +594,8 @@ func purgeTrashedSessionFile(dir, path string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dm := loadSessionDisplays(dir); dm[key] != nil {
|
||||
delete(dm, key)
|
||||
if err := saveSessionDisplays(dir, dm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionDisplayKey(dir, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -580,11 +728,15 @@ func trashSubagentArtifacts(dir, sessionPath, itemDir string) error {
|
||||
}
|
||||
trashSubagentDir := filepath.Join(itemDir, "subagents")
|
||||
for _, artifact := range artifacts {
|
||||
if err := movePathIfExists(artifact.SessionPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.SessionPath))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := movePathIfExists(artifact.MetaPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.MetaPath))); err != nil {
|
||||
return err
|
||||
paths := []string{artifact.SessionPath, artifact.MetaPath}
|
||||
paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
|
||||
for _, src := range paths {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
continue
|
||||
}
|
||||
if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -649,7 +801,7 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if filepath.Ext(absPath) != ".jsonl" {
|
||||
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
||||
return "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
}
|
||||
rel, err := filepath.Rel(absDir, absPath)
|
||||
@@ -694,7 +846,7 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if filepath.Ext(absPath) != ".jsonl" {
|
||||
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
||||
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
@@ -702,9 +854,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)
|
||||
@@ -853,6 +1015,84 @@ func saveSessionDisplays(dir string, m sessionDisplayMap) error {
|
||||
return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir))
|
||||
}
|
||||
|
||||
func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
|
||||
if len(m) == 0 {
|
||||
err := os.Remove(sessionDisplayPath(dir))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return saveSessionDisplays(dir, m)
|
||||
}
|
||||
|
||||
func removeSessionDisplayKey(dir, key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
m := loadSessionDisplays(dir)
|
||||
if m[key] == nil {
|
||||
return nil
|
||||
}
|
||||
delete(m, key)
|
||||
return saveOrRemoveSessionDisplays(dir, m)
|
||||
}
|
||||
|
||||
func removeSessionDisplay(dir, sessionPath string) error {
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return nil
|
||||
}
|
||||
return removeSessionDisplayKey(dir, filepath.Base(sessionPath))
|
||||
}
|
||||
|
||||
func pruneSessionDisplays(dir string, protected map[string]struct{}) error {
|
||||
m := loadSessionDisplays(dir)
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for key := range m {
|
||||
if sessionDisplayKeyStillOwned(dir, key, protected) {
|
||||
continue
|
||||
}
|
||||
delete(m, key)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return saveOrRemoveSessionDisplays(dir, m)
|
||||
}
|
||||
|
||||
func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) {
|
||||
return false
|
||||
}
|
||||
if protected != nil {
|
||||
if _, ok := protected[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
sessionPath := filepath.Join(dir, key)
|
||||
if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
trashPath := filepath.Join(sessionTrashPath(dir), key, key)
|
||||
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
|
||||
}
|
||||
|
||||
func recordSessionDisplay(dir, sessionPath, content, display string) error {
|
||||
if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// simulateForeignSessionLeaseHolder holds path's lease the way another process
|
||||
// would: the on-disk info names a foreign writer and a raw flock on a separate
|
||||
// fd keeps the lock file locked without registering in this process's
|
||||
// in-process lease bookkeeping (flock excludes other fds even within one
|
||||
// process, so this faithfully mimics a foreign holder).
|
||||
func simulateForeignSessionLeaseHolder(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := agent.SaveSessionLeaseInfo(path, agent.SessionLeaseInfo{
|
||||
SessionPath: path,
|
||||
WriterID: "other-host-4242-cafebabe",
|
||||
PID: os.Getpid() + 1,
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSessionLeaseInfo: %v", err)
|
||||
}
|
||||
f, err := os.OpenFile(path+".lease.lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("open lease lock: %v", err)
|
||||
}
|
||||
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatalf("flock lease lock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path + ".lease.lock")
|
||||
_ = os.Remove(path + ".lease.json")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteSessionKeepsDuplicateLiveSessionHeldByOtherRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "duplicate-foreign-held.jsonl")
|
||||
content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("write live session: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
|
||||
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
|
||||
t.Fatalf("create trash dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(trashPath, content, 0o644); err != nil {
|
||||
t.Fatalf("write trash session: %v", err)
|
||||
}
|
||||
simulateForeignSessionLeaseHolder(t, path)
|
||||
|
||||
activePath := filepath.Join(dir, "active.jsonl")
|
||||
if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write active session: %v", err)
|
||||
}
|
||||
activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
|
||||
defer activeCtrl.Close()
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
|
||||
activeTabID: "active",
|
||||
tabOrder: []string{"active"},
|
||||
}
|
||||
|
||||
err := app.DeleteSession(filepath.Base(path))
|
||||
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) {
|
||||
t.Fatalf("live session must stay intact, got %q err=%v", string(got), readErr)
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,18 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
os.WriteFile(metaPath, []byte("{}"), 0o644)
|
||||
goalPath := store.SessionGoalState(sessionPath)
|
||||
os.WriteFile(goalPath, []byte(`{"goal":"ship"}`), 0o644)
|
||||
eventLogPath := store.SessionEventLog(sessionPath)
|
||||
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)
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
|
||||
lockPath := store.SessionLockFile(sessionPath)
|
||||
os.WriteFile(lockPath, nil, 0o644)
|
||||
leaseLockPath := store.SessionLeaseLock(sessionPath)
|
||||
os.WriteFile(leaseLockPath, nil, 0o644)
|
||||
leaseInfoPath := store.SessionLeaseInfo(sessionPath)
|
||||
os.WriteFile(leaseInfoPath, []byte(`{"writer_id":"stale"}`), 0o644)
|
||||
ckptDir := filepath.Join(dir, "session.ckpt")
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir ckpt: %v", err)
|
||||
@@ -192,6 +202,8 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
||||
trashMetaPath := trashPath + ".meta"
|
||||
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")
|
||||
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")
|
||||
@@ -206,9 +218,24 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
|
||||
t.Error("session goal state should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(eventLogPath); !os.IsNotExist(err) {
|
||||
t.Error("session event log should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
|
||||
t.Error("session event index should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
|
||||
t.Error("session telemetry should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
||||
t.Error("session lock should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(leaseLockPath); !os.IsNotExist(err) {
|
||||
t.Error("session lease lock should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(leaseInfoPath); !os.IsNotExist(err) {
|
||||
t.Error("session lease info should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
|
||||
t.Error("session checkpoints should be removed from active sessions")
|
||||
}
|
||||
@@ -224,9 +251,24 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(trashGoalPath); err != nil {
|
||||
t.Fatalf("session goal state should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashEventLogPath); err != nil {
|
||||
t.Fatalf("session event log should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashEventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashTelemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be in trash: %v", err)
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lock"),
|
||||
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.lock"),
|
||||
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.json"),
|
||||
} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatalf("ephemeral session artifact should not be moved to trash: %s (err=%v)", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(trashCkptDir); err != nil {
|
||||
t.Fatalf("session checkpoints should be in trash: %v", err)
|
||||
}
|
||||
@@ -442,6 +484,14 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if err := os.WriteFile(goalPath, []byte(`{"goal":"restore"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventLogPath := store.SessionEventLog(sessionPath)
|
||||
if err := os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventIndexPath := store.SessionEventIndex(sessionPath)
|
||||
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -481,6 +531,12 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(goalPath); err != nil {
|
||||
t.Fatalf("session goal state should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(eventLogPath); err != nil {
|
||||
t.Fatalf("session event log should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(eventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be restored: %v", err)
|
||||
}
|
||||
@@ -523,6 +579,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")
|
||||
@@ -731,12 +832,20 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
sessionPath,
|
||||
store.SessionMeta(sessionPath),
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
store.SessionLeaseInfo(sessionPath),
|
||||
} {
|
||||
if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 2 lines]"); err != nil {
|
||||
t.Fatalf("record display: %v", err)
|
||||
}
|
||||
ckptDir := store.SessionCheckpointDir(sessionPath)
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -760,7 +869,12 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
sessionPath,
|
||||
store.SessionMeta(sessionPath),
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
store.SessionLeaseInfo(sessionPath),
|
||||
ckptDir,
|
||||
jobsDir,
|
||||
} {
|
||||
@@ -768,6 +882,9 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
t.Fatalf("%s should be removed, stat err = %v", p, err)
|
||||
}
|
||||
}
|
||||
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
|
||||
t.Fatalf("session display key should be removed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionFileRejectsOutsideDir(t *testing.T) {
|
||||
@@ -956,6 +1073,48 @@ func TestSessionDisplayRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSessionDisplaysRemovesOnlyOrphans(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "expanded prompt"
|
||||
if err := saveSessionDisplays(dir, sessionDisplayMap{
|
||||
"live.jsonl": map[string]string{messageDisplayKey(content): "live display"},
|
||||
"trashed.jsonl": map[string]string{messageDisplayKey(content): "trash display"},
|
||||
"protected.jsonl": map[string]string{messageDisplayKey(content): "protected display"},
|
||||
"missing.jsonl": map[string]string{messageDisplayKey(content): "missing display"},
|
||||
"sidecar.events.jsonl": map[string]string{
|
||||
messageDisplayKey(content): "invalid display",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save displays: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write live session: %v", err)
|
||||
}
|
||||
trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir trash: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write trashed session: %v", err)
|
||||
}
|
||||
|
||||
if err := pruneSessionDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
|
||||
t.Fatalf("prune displays: %v", err)
|
||||
}
|
||||
|
||||
got := loadSessionDisplays(dir)
|
||||
for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
|
||||
if got[key] == nil {
|
||||
t.Fatalf("%s display should be retained; got %#v", key, got)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
|
||||
if got[key] != nil {
|
||||
t.Fatalf("%s display should be pruned; got %#v", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSessionDisplaySkipsNoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
|
||||
+516
-171
File diff suppressed because it is too large.
Load diff
@@ -1217,3 +1217,134 @@ func TestSaveHooksSettingsForRootUsesDisplayedProjectRoot(t *testing.T) {
|
||||
t.Fatal("active project root was written instead of displayed project root")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess locks the
|
||||
// read-path contract: loading a legacy-form config (configured providers but
|
||||
// no declared desktop.provider_access) through the View helpers returns a
|
||||
// normalized in-memory view while leaving the file bytes untouched. The
|
||||
// on-disk migration only happens once a locked write path runs.
|
||||
func TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userPath := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := "default_model = \"local/m1\"\n\n[[providers]]\nname = \"local\"\nbase_url = \"http://127.0.0.1:9999/v1\"\nmodels = [\"m1\"]\n"
|
||||
if err := os.WriteFile(userPath, []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
for name, load := range map[string]func() (*config.Config, string, error){
|
||||
"view": app.loadDesktopUserConfigForView,
|
||||
"view-with-credentials": app.loadDesktopUserConfigForViewWithCredentials,
|
||||
} {
|
||||
cfg, _, err := load()
|
||||
if err != nil {
|
||||
t.Fatalf("%s load: %v", name, err)
|
||||
}
|
||||
if len(cfg.Desktop.ProviderAccess) == 0 {
|
||||
t.Fatalf("%s load should normalize legacy provider access in memory", name)
|
||||
}
|
||||
raw, err := os.ReadFile(userPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != legacy {
|
||||
t.Fatalf("%s load must not rewrite the user config, got:\n%s", name, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The first locked write path persists the pending migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
if !configDeclaresProviderAccess(userPath) {
|
||||
t.Fatal("locked write path should persist the provider access migration to disk")
|
||||
}
|
||||
migrated := config.LoadForEditWithoutCredentials(userPath)
|
||||
if len(migrated.Desktop.ProviderAccess) == 0 {
|
||||
t.Fatalf("migrated config lost provider access: %v", migrated.Desktop.ProviderAccess)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory locks the
|
||||
// same contract for the legacy bot-config migration: read paths (including the
|
||||
// bot runtime's credential-loading view) see the merged bot config in memory
|
||||
// without any file being written; the locked write path performs the on-disk
|
||||
// migration.
|
||||
func TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userPath := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userBody := "default_model = \"local/m1\"\n"
|
||||
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyRoot := t.TempDir()
|
||||
legacyPath := filepath.Join(legacyRoot, "reasonix.toml")
|
||||
legacyBody := "[bot]\nenabled = true\nmodel = \"local/m1\"\n"
|
||||
if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"t": {ID: "t", Scope: "project", WorkspaceRoot: legacyRoot, Ready: true},
|
||||
}
|
||||
app.activeTabID = "t"
|
||||
|
||||
assertFilesUntouched := func(step string) {
|
||||
t.Helper()
|
||||
rawUser, err := os.ReadFile(userPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rawUser) != userBody {
|
||||
t.Fatalf("%s must not rewrite the user config, got:\n%s", step, rawUser)
|
||||
}
|
||||
rawLegacy, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rawLegacy) != legacyBody {
|
||||
t.Fatalf("%s must not rewrite the legacy config, got:\n%s", step, rawLegacy)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, _, err := app.loadDesktopUserConfigForView()
|
||||
if err != nil {
|
||||
t.Fatalf("loadDesktopUserConfigForView: %v", err)
|
||||
}
|
||||
if !cfg.Bot.Enabled {
|
||||
t.Fatal("view load should merge the legacy bot config in memory")
|
||||
}
|
||||
assertFilesUntouched("loadDesktopUserConfigForView")
|
||||
|
||||
botCfg, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadDesktopBotConfig: %v", err)
|
||||
}
|
||||
if !botCfg.Bot.Enabled {
|
||||
t.Fatal("bot runtime load should see the merged legacy bot config")
|
||||
}
|
||||
assertFilesUntouched("loadDesktopBotConfig")
|
||||
|
||||
// The first locked write path migrates the bot config into the user file.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
migrated := config.LoadForEditWithoutCredentials(userPath)
|
||||
if !migrated.Bot.Enabled {
|
||||
t.Fatal("locked write path should persist the legacy bot config migration")
|
||||
}
|
||||
rawLegacy, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rawLegacy) != legacyBody {
|
||||
t.Fatalf("migration must not rewrite the legacy config, got:\n%s", rawLegacy)
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -135,12 +135,33 @@ func (a *App) releaseSharedHost(root string) {
|
||||
}
|
||||
|
||||
func (a *App) releaseTabSharedHost(tab *WorkspaceTab) {
|
||||
if tab == nil || tab.SharedHostKey == "" {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
// SharedHostKey is a.mu-guarded (the build goroutine publishes it under
|
||||
// the lock); do the take under the lock and the slow host release after.
|
||||
// Callers must not hold a.mu.
|
||||
a.mu.Lock()
|
||||
key := takeTabSharedHostKey(tab)
|
||||
a.mu.Unlock()
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
a.releaseSharedHost(key)
|
||||
}
|
||||
|
||||
// takeTabSharedHostKey clears the tab's shared-host key and returns it so the
|
||||
// caller can release it later. Use from inside a.mu critical sections:
|
||||
// releaseSharedHost may close the host and reap MCP subprocesses, which is far
|
||||
// too slow to run under the app lock — call a.releaseSharedHost(key) after
|
||||
// unlocking.
|
||||
func takeTabSharedHostKey(tab *WorkspaceTab) string {
|
||||
if tab == nil || tab.SharedHostKey == "" {
|
||||
return ""
|
||||
}
|
||||
key := tab.SharedHostKey
|
||||
tab.SharedHostKey = ""
|
||||
a.releaseSharedHost(key)
|
||||
return key
|
||||
}
|
||||
|
||||
// closeAllSharedHosts closes every shared host. Called during app shutdown.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+1409
-416
File diff suppressed because it is too large.
Load diff
+208
-3
@@ -74,6 +74,30 @@ func (s *snapshotObservingSession) Snapshot() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
|
||||
t.Helper()
|
||||
return func() {
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
app.mu.Lock()
|
||||
app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
|
||||
close(acquired)
|
||||
}()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Error("Snapshot ran while holding app mutex")
|
||||
}
|
||||
if checks == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case checks <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
||||
|
||||
@@ -182,6 +206,90 @@ func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
|
||||
ctrl.Close()
|
||||
}
|
||||
|
||||
func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
tabOrder: []string{"hidden", "target"},
|
||||
activeTabID: "hidden",
|
||||
}
|
||||
snapshotChecks := make(chan struct{}, 2)
|
||||
hiddenCtrl := &snapshotObservingSession{
|
||||
SessionAPI: control.New(control.Options{Label: "hidden"}),
|
||||
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
||||
}
|
||||
hidden := &WorkspaceTab{
|
||||
ID: "hidden",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
TopicID: "topic-hidden",
|
||||
Ctrl: hiddenCtrl,
|
||||
Ready: true,
|
||||
sink: &tabEventSink{tabID: "hidden"},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
target := &WorkspaceTab{
|
||||
ID: "target",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs["hidden"] = hidden
|
||||
app.tabs["target"] = target
|
||||
|
||||
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
||||
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-snapshotChecks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("hidden tab was not snapshotted before pruning")
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "target")
|
||||
}
|
||||
|
||||
func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
tabOrder: []string{"closing", "survivor"},
|
||||
activeTabID: "closing",
|
||||
}
|
||||
snapshotChecks := make(chan struct{}, 1)
|
||||
closingCtrl := &snapshotObservingSession{
|
||||
SessionAPI: control.New(control.Options{Label: "closing"}),
|
||||
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
||||
}
|
||||
closing := &WorkspaceTab{
|
||||
ID: "closing",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
TopicID: "topic-closing",
|
||||
Ctrl: closingCtrl,
|
||||
Ready: true,
|
||||
sink: &tabEventSink{tabID: "closing"},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs["closing"] = closing
|
||||
app.tabs["survivor"] = survivor
|
||||
|
||||
if err := app.CloseTab("closing"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-snapshotChecks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("closing tab was not snapshotted")
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "survivor")
|
||||
}
|
||||
|
||||
func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
cancelled := false
|
||||
@@ -293,6 +401,60 @@ func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
|
||||
acquired := make(chan struct{})
|
||||
releaseHook := make(chan struct{})
|
||||
var once sync.Once
|
||||
sessionLeaseAcquireHookForTest = func() {
|
||||
once.Do(func() {
|
||||
close(acquired)
|
||||
<-releaseHook
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
|
||||
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
firstErr <- tab.ensureSessionLease(path)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
case err := <-firstErr:
|
||||
t.Fatalf("first ensureSessionLease returned before hook: %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first ensureSessionLease did not acquire lease")
|
||||
}
|
||||
|
||||
secondErr := make(chan error, 1)
|
||||
go func() {
|
||||
secondErr <- tab.ensureSessionLease(path)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-secondErr:
|
||||
t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(releaseHook)
|
||||
if err := <-firstErr; err != nil {
|
||||
t.Fatalf("first ensureSessionLease: %v", err)
|
||||
}
|
||||
if err := <-secondErr; err != nil {
|
||||
t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
@@ -399,6 +561,38 @@ func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
|
||||
},
|
||||
tabOrder: []string{"project", "global"},
|
||||
activeTabID: "project",
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
}
|
||||
snapshotChecks := make(chan struct{}, 1)
|
||||
app.tabs["project"].Ctrl = &snapshotObservingSession{
|
||||
SessionAPI: control.New(control.Options{Label: "project"}),
|
||||
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
||||
}
|
||||
|
||||
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
||||
t.Fatalf("RemoveWorkspace: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-snapshotChecks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("project tab was not snapshotted before removing workspace")
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "global")
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
@@ -454,12 +648,16 @@ func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
errs := make(chan string, 8)
|
||||
iterations := 100
|
||||
if testing.Short() {
|
||||
iterations = 5
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for j := 0; j < 100; j++ {
|
||||
for j := 0; j < iterations; j++ {
|
||||
if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
|
||||
errs <- got
|
||||
return
|
||||
@@ -711,8 +909,15 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -491,6 +491,333 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
|
||||
topicID := "legacy_desktop-legacy_1234"
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
ID: agent.BranchID(sessionPath),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: "你是谁",
|
||||
Turns: 1,
|
||||
Preview: "who are you",
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta: %v", err)
|
||||
}
|
||||
markTopicMigrationDone(dir)
|
||||
|
||||
repaired := migrateLegacySessionsIntoGlobalTopics(dir)
|
||||
if len(repaired) != 1 || repaired[0] != topicID {
|
||||
t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
var global *ProjectNode
|
||||
for i := range nodes {
|
||||
if nodes[i].Kind == "global_folder" {
|
||||
global = &nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if global == nil {
|
||||
t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
|
||||
}
|
||||
if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
|
||||
t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if !containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
|
||||
}
|
||||
if got := loadTopicTitle("", topicID); got != "你是谁" {
|
||||
t.Fatalf("global topic title = %q, want 你是谁", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
|
||||
topicID := "legacy_desktop-delete_1234"
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
ID: agent.BranchID(sessionPath),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: "临时 Global",
|
||||
Turns: 1,
|
||||
Preview: "delete repaired topic",
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta: %v", err)
|
||||
}
|
||||
markTopicMigrationDone(dir)
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
|
||||
t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
|
||||
}
|
||||
if err := NewApp().DeleteTopic(topicID); err != nil {
|
||||
t.Fatalf("delete repaired topic: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
later := time.Now()
|
||||
if err := os.Chtimes(sessionPath, later, later); err != nil {
|
||||
t.Fatalf("touch session after delete: %v", err)
|
||||
}
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
|
||||
t.Fatalf("deleted repaired topic was restored: %#v", repaired)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
|
||||
}
|
||||
if got := loadTopicTitle("", topicID); got != "" {
|
||||
t.Fatalf("global topic title = %q, want deleted", got)
|
||||
}
|
||||
if !containsDesktopString(f.DeletedTopics, topicID) {
|
||||
t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
writeIndexedSession := func(name, topicID, title string, at time.Time) string {
|
||||
p := writeLegacySession(t, dir, name, "prompt "+title, at)
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
|
||||
ID: agent.BranchID(p),
|
||||
CreatedAt: at.Add(-time.Hour),
|
||||
UpdatedAt: at,
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: title,
|
||||
Turns: 1,
|
||||
Preview: "prompt " + title,
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta %s: %v", name, err)
|
||||
}
|
||||
if err := os.Chtimes(p, at, at); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
now := time.Now()
|
||||
olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
|
||||
writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
|
||||
markTopicMigrationDone(dir)
|
||||
|
||||
// First pass repairs both missing topics.
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
|
||||
t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
|
||||
}
|
||||
before := loadProjectsFile()
|
||||
projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
|
||||
statBefore, err := os.Stat(projPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat projects file: %v", err)
|
||||
}
|
||||
|
||||
// Ordinary session activity invalidates the repair marker; the rescan must
|
||||
// not reorder the indexed topics, rewrite the projects file, or report the
|
||||
// already-visible topics as repaired (the callers bind blank Global tabs
|
||||
// to repaired[0]).
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
later := time.Now()
|
||||
if err := os.Chtimes(olderPath, later, later); err != nil {
|
||||
t.Fatalf("touch session: %v", err)
|
||||
}
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
|
||||
t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
|
||||
}
|
||||
after := loadProjectsFile()
|
||||
if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
|
||||
t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
|
||||
}
|
||||
statAfter, err := os.Stat(projPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat projects file: %v", err)
|
||||
}
|
||||
if !statAfter.ModTime().Equal(statBefore.ModTime()) {
|
||||
t.Fatalf("steady-state rescan rewrote the projects file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPrependRespectsTombstoneWrittenAfterScanSnapshot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
topicID := "legacy_race_000000000001"
|
||||
// Simulate a DeleteTopic landing between a repair scan's DeletedTopics
|
||||
// snapshot and its batch write: the tombstone exists by the time the
|
||||
// prepend runs, so the batch must drop the topic instead of resurrecting it.
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
|
||||
t.Fatalf("batch prepend: %v", err)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
|
||||
}
|
||||
if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
|
||||
t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
|
||||
}
|
||||
if !containsDesktopString(f.DeletedTopics, topicID) {
|
||||
t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
|
||||
}
|
||||
|
||||
// Intentional single-topic writes (create/restore/tab indexing) clear the
|
||||
// tombstone and bring the topic back in the same projects-file transaction.
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("single prepend: %v", err)
|
||||
}
|
||||
f = loadProjectsFile()
|
||||
if !containsDesktopString(f.GlobalTopics, topicID) {
|
||||
t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
|
||||
}
|
||||
if containsDesktopString(f.DeletedTopics, topicID) {
|
||||
t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
// Control: a legitimate title-only topic (not in GlobalTopics) must keep
|
||||
// rendering through the orderedTopicIDs title-map fallback.
|
||||
controlID := "topic_control_visible"
|
||||
if err := setTopicTitle("", controlID, "正常话题"); err != nil {
|
||||
t.Fatalf("set control title: %v", err)
|
||||
}
|
||||
// Race product: DeleteTopic landed, but a stale whole-map save wrote the
|
||||
// topic's title back — tombstoned, absent from GlobalTopics, title present.
|
||||
tombstonedID := "legacy_raced_000000000009"
|
||||
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
|
||||
t.Fatalf("set stale title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
var global *ProjectNode
|
||||
for i := range nodes {
|
||||
if nodes[i].Kind == "global_folder" {
|
||||
global = &nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if global == nil {
|
||||
t.Fatalf("project tree = %#v, want Global folder", nodes)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range global.Children {
|
||||
seen[c.TopicID] = true
|
||||
}
|
||||
if seen[tombstonedID] {
|
||||
t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
|
||||
}
|
||||
if !seen[controlID] {
|
||||
t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
// Stale race product on disk: tombstoned topic whose title lingers in the
|
||||
// global title map. The next repair pass that saves the whole map must
|
||||
// prune it instead of persisting it again.
|
||||
tombstonedID := "legacy_raced_000000000010"
|
||||
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
|
||||
t.Fatalf("set stale title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
|
||||
repairID := "legacy_desktop-prune_1234"
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
ID: agent.BranchID(sessionPath),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: repairID,
|
||||
TopicTitle: "待修复话题",
|
||||
Turns: 1,
|
||||
Preview: "needs repair",
|
||||
}); err != nil {
|
||||
t.Fatalf("save meta: %v", err)
|
||||
}
|
||||
markTopicMigrationDone(dir)
|
||||
|
||||
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
|
||||
t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
|
||||
}
|
||||
if got := loadTopicTitle("", tombstonedID); got != "" {
|
||||
t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
|
||||
}
|
||||
if got := loadTopicTitle("", repairID); got != "待修复话题" {
|
||||
t.Fatalf("repaired title = %q, want 待修复话题", got)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
|
||||
}
|
||||
if !containsDesktopString(f.GlobalTopics, repairID) {
|
||||
t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
@@ -1170,6 +1497,27 @@ func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTreeFallsBackToTopicIDCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "legacy_20260606-114914_2276f13fd87c"
|
||||
if err := setTopicTitleWithSource("", topicID, "你好,你是谁", topicTitleSourceManual); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
expected := time.Date(2026, 6, 6, 11, 49, 14, 0, time.UTC).UnixMilli()
|
||||
if got := nodes[0].Children[0].CreatedAt; got != expected {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
+29
-13
@@ -38,7 +38,7 @@ func (a *App) startTray() bool {
|
||||
a.tray = t
|
||||
a.mu.Unlock()
|
||||
|
||||
t.end = startDesktopTray(func() {
|
||||
end := startDesktopTray(func() {
|
||||
systray.SetIcon(trayIconBytes)
|
||||
systray.SetTitle("Reasonix")
|
||||
systray.SetTooltip("Reasonix")
|
||||
@@ -51,21 +51,25 @@ func (a *App) startTray() bool {
|
||||
systray.SetOnSecondaryTapped(nil)
|
||||
|
||||
labels := trayMenuLabels(a.trayLocale())
|
||||
t.openItem = systray.AddMenuItem(labels.openTitle, labels.openTooltip)
|
||||
t.quitItem = systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
|
||||
openItem := systray.AddMenuItem(labels.openTitle, labels.openTooltip)
|
||||
quitItem := systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
|
||||
|
||||
// Publish the menu items under a.mu: this callback runs on the systray
|
||||
// goroutine while bound settings calls (updateTrayLocale) read them.
|
||||
a.mu.Lock()
|
||||
t.openItem = openItem
|
||||
t.quitItem = quitItem
|
||||
a.trayReady = true
|
||||
a.mu.Unlock()
|
||||
t.markReady()
|
||||
|
||||
a.goSafe("trayOpenLoop", func() {
|
||||
for range t.openItem.ClickedCh {
|
||||
for range openItem.ClickedCh {
|
||||
a.showFromTray()
|
||||
}
|
||||
})
|
||||
a.goSafe("trayQuitLoop", func() {
|
||||
for range t.quitItem.ClickedCh {
|
||||
for range quitItem.ClickedCh {
|
||||
a.quitFromTray()
|
||||
}
|
||||
})
|
||||
@@ -77,35 +81,47 @@ func (a *App) startTray() bool {
|
||||
}
|
||||
a.mu.Unlock()
|
||||
})
|
||||
a.mu.Lock()
|
||||
t.end = end
|
||||
a.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) stopTray() {
|
||||
a.mu.RLock()
|
||||
t := a.tray
|
||||
var end func()
|
||||
if t != nil {
|
||||
end = t.end
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if t == nil || t.end == nil {
|
||||
if t == nil || end == nil {
|
||||
return
|
||||
}
|
||||
t.once.Do(t.end)
|
||||
t.once.Do(end)
|
||||
}
|
||||
|
||||
func (a *App) updateTrayLocale(locale string) {
|
||||
a.mu.RLock()
|
||||
t := a.tray
|
||||
var openItem, quitItem *systray.MenuItem
|
||||
if t != nil {
|
||||
openItem = t.openItem
|
||||
quitItem = t.quitItem
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if t == nil || t.openItem == nil || t.quitItem == nil {
|
||||
if openItem == nil || quitItem == nil {
|
||||
return
|
||||
}
|
||||
labels := trayMenuLabels(locale)
|
||||
t.openItem.SetTitle(labels.openTitle)
|
||||
t.openItem.SetTooltip(labels.openTooltip)
|
||||
t.quitItem.SetTitle(labels.quitTitle)
|
||||
t.quitItem.SetTooltip(labels.quitTooltip)
|
||||
openItem.SetTitle(labels.openTitle)
|
||||
openItem.SetTooltip(labels.openTooltip)
|
||||
quitItem.SetTitle(labels.quitTitle)
|
||||
quitItem.SetTooltip(labels.quitTooltip)
|
||||
}
|
||||
|
||||
func (a *App) trayLocale() string {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
cfg, _, err := a.loadDesktopUserConfigForView()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
+55
-18
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -34,11 +35,12 @@ 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
|
||||
// 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.
|
||||
const (
|
||||
r2Base = "https://dl.reasonix.io"
|
||||
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
|
||||
@@ -46,8 +48,19 @@ 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. The URL is safe despite
|
||||
// the repository-wide /releases/latest caveat above: release.yml pins the
|
||||
// repo-wide latest badge to the CLI line and attaches a desktop-manifest mirror
|
||||
// to every stable CLI release ("desktop manifest compatibility asset"), so this
|
||||
// asset is always the desktop manifest. 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 +71,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 +169,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 +238,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 +453,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 +495,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()))
|
||||
}
|
||||
@@ -572,13 +601,10 @@ func applyLinux(targz []byte) error {
|
||||
}
|
||||
|
||||
func applyWindowsFile(path string) error {
|
||||
return installerCommand(path, currentInstallDir()).Start()
|
||||
return startWindowsUpdateHandoff(path, currentInstallDir(), currentExecutablePath())
|
||||
}
|
||||
|
||||
// currentInstallDir is the directory of the running executable — the location a
|
||||
// Windows update must overwrite. Empty when it can't be resolved, in which case
|
||||
// the installer falls back to its own InstallDir logic.
|
||||
func currentInstallDir() string {
|
||||
func currentExecutablePath() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -586,6 +612,17 @@ func currentInstallDir() string {
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return exe
|
||||
}
|
||||
|
||||
// currentInstallDir is the directory of the running executable — the location a
|
||||
// Windows update must overwrite. Empty when it can't be resolved, in which case
|
||||
// the installer falls back to its own InstallDir logic.
|
||||
func currentInstallDir() string {
|
||||
exe := currentExecutablePath()
|
||||
if exe == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,7 @@ import "os/exec"
|
||||
func installerCommand(name, _ string) *exec.Cmd {
|
||||
return exec.Command(name)
|
||||
}
|
||||
|
||||
func startWindowsUpdateHandoff(name, dir, _ string) error {
|
||||
return installerCommand(name, dir).Start()
|
||||
}
|
||||
@@ -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). release.yml pins the desktop
|
||||
// manifest mirror to the repo-wide latest release, which is what makes this
|
||||
// URL safe; 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)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const windowsUpdateHelperFileName = "reasonix-update-helper.exe"
|
||||
|
||||
// installerCommand runs the NSIS updater, forcing $INSTDIR to dir via /D= so the
|
||||
// update overwrites the current install in place. NSIS requires /D= to be the
|
||||
// final, unquoted token taken verbatim to the end of the line, so the raw command
|
||||
@@ -15,8 +19,54 @@ import (
|
||||
// C:\Users\Jane Doe\...) and NSIS would then mis-parse the target directory.
|
||||
func installerCommand(name, dir string) *exec.Cmd {
|
||||
cmd := exec.Command(name)
|
||||
if dir != "" {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: fmt.Sprintf(`"%s" /D=%s`, name, dir)}
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(name, dir), HideWindow: true}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func startWindowsUpdateHandoff(installerPath, installDir, relaunchPath string) error {
|
||||
if err := startWindowsUpdateHelper(installerPath, installDir, relaunchPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
return installerCommand(installerPath, installDir).Start()
|
||||
}
|
||||
|
||||
func startWindowsUpdateHelper(installerPath, installDir, relaunchPath string) error {
|
||||
if installDir == "" {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
helperPath, err := prepareWindowsUpdateHelper(installDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(helperPath, windowsUpdateHandoffArgs(os.Getpid(), installerPath, installDir, relaunchPath)...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func prepareWindowsUpdateHelper(installDir string) (string, error) {
|
||||
src := filepath.Join(installDir, windowsUpdateHelperFileName)
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir, err := updateCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanupWindowsUpdateHelpers(dir)
|
||||
dst := filepath.Join(dir, "reasonix-update-helper-"+time.Now().UTC().Format("20060102150405.000000000")+".exe")
|
||||
if err := os.WriteFile(dst, data, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func cleanupWindowsUpdateHelpers(dir string) {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "reasonix-update-helper-*.exe"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, name := range matches {
|
||||
_ = os.Remove(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
line := fmt.Sprintf(`"%s" /S`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func windowsUpdateHandoffArgs(parentPID int, installer, installDir, relaunch string) []string {
|
||||
args := []string{
|
||||
"--parent-pid", strconv.Itoa(parentPID),
|
||||
"--installer", installer,
|
||||
}
|
||||
if installDir != "" {
|
||||
args = append(args, "--install-dir", installDir)
|
||||
}
|
||||
if relaunch != "" {
|
||||
args = append(args, "--relaunch", relaunch)
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -10,7 +10,7 @@ func TestInstallerCommandPassesUnquotedDFlagLast(t *testing.T) {
|
||||
t.Fatal("expected a raw command line forcing the install dir")
|
||||
}
|
||||
got := cmd.SysProcAttr.CmdLine
|
||||
want := `"C:\Temp\reasonix-update-1.exe" /D=D:\Tools\Reasonix App`
|
||||
want := `"C:\Temp\reasonix-update-1.exe" /S /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("CmdLine = %q, want %q", got, want)
|
||||
}
|
||||
@@ -18,7 +18,12 @@ func TestInstallerCommandPassesUnquotedDFlagLast(t *testing.T) {
|
||||
|
||||
func TestInstallerCommandWithoutDirSkipsDFlag(t *testing.T) {
|
||||
cmd := installerCommand(`C:\Temp\reasonix-update-1.exe`, "")
|
||||
if cmd.SysProcAttr != nil {
|
||||
t.Fatalf("no dir should leave NSIS InstallDir logic intact, got CmdLine %q", cmd.SysProcAttr.CmdLine)
|
||||
if cmd.SysProcAttr == nil {
|
||||
t.Fatal("expected a raw command line for silent updater installs")
|
||||
}
|
||||
got := cmd.SysProcAttr.CmdLine
|
||||
want := `"C:\Temp\reasonix-update-1.exe" /S`
|
||||
if got != want {
|
||||
t.Fatalf("CmdLine = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineIsSilentAndKeepsDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsUpdateHandoffArgsCarryParentInstallAndRelaunch(t *testing.T) {
|
||||
got := windowsUpdateHandoffArgs(
|
||||
4242,
|
||||
`C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
|
||||
`D:\Tools\Reasonix App`,
|
||||
`D:\Tools\Reasonix App\reasonix-desktop.exe`,
|
||||
)
|
||||
want := []string{
|
||||
"--parent-pid", "4242",
|
||||
"--installer", `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
|
||||
"--install-dir", `D:\Tools\Reasonix App`,
|
||||
"--relaunch", `D:\Tools\Reasonix App\reasonix-desktop.exe`,
|
||||
}
|
||||
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
|
||||
t.Fatalf("args = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
|
||||
data, err := os.ReadFile("build/windows/installer/project.nsi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(data)
|
||||
for _, want := range []string{
|
||||
`!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"`,
|
||||
"Function reasonix.waitForExecutableUnlock",
|
||||
`FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a`,
|
||||
"SetErrorLevel 1618",
|
||||
"Call reasonix.waitForExecutableUnlock",
|
||||
`File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"`,
|
||||
`Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("project.nsi missing %q", want)
|
||||
}
|
||||
}
|
||||
wait := strings.Index(script, "Call reasonix.waitForExecutableUnlock")
|
||||
copyFiles := strings.Index(script, "!insertmacro wails.files")
|
||||
if wait < 0 || copyFiles < 0 || wait > copyFiles {
|
||||
t.Fatalf("installer must wait for the running exe to unlock before wails.files (wait=%d copy=%d)", wait, copyFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBuildScriptCompilesAndPackagesWindowsUpdateHelper(t *testing.T) {
|
||||
data, err := os.ReadFile("../scripts/desktop-build.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(data)
|
||||
for _, want := range []string{
|
||||
`UPDATE_HELPER="reasonix-update-helper.exe"`,
|
||||
`GOOS=windows GOARCH="$arch" go build`,
|
||||
`./cmd/update-helper`,
|
||||
`build/windows/installer/$UPDATE_HELPER`,
|
||||
`cp "$helper" "$staging/$UPDATE_HELPER"`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("desktop-build.sh missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
@@ -26,6 +27,18 @@ type workspaceChangeAccumulator struct {
|
||||
hasGit bool
|
||||
}
|
||||
|
||||
const workspaceGitBranchCacheTTL = 2 * time.Second
|
||||
|
||||
type workspaceGitBranchCacheEntry struct {
|
||||
branch string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
var workspaceGitBranchCache = struct {
|
||||
sync.Mutex
|
||||
entries map[string]workspaceGitBranchCacheEntry
|
||||
}{entries: map[string]workspaceGitBranchCacheEntry{}}
|
||||
|
||||
func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
|
||||
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
@@ -149,6 +162,12 @@ func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return workspaceGitCommand(ctx, args...).Output()
|
||||
}
|
||||
|
||||
func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
|
||||
cmd := workspaceGit("-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all")
|
||||
raw, err := cmd.Output()
|
||||
@@ -225,15 +244,40 @@ func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
|
||||
return normalizeWorkspaceRelPath(base, path)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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) {
|
||||
workspaceGitBranchCache.Unlock()
|
||||
return cached.branch
|
||||
}
|
||||
workspaceGitBranchCache.Unlock()
|
||||
|
||||
branch := workspaceGitBranch(base)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// workspaceGitBranch returns the current git branch name for the repo rooted
|
||||
// at base, or an empty string when base is not inside a git repository or when
|
||||
// git is unavailable.
|
||||
func workspaceGitBranch(base string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := workspaceGitCommand(ctx, "-C", base, "branch", "--show-current")
|
||||
raw, err := cmd.Output()
|
||||
raw, err := workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -241,8 +285,7 @@ func workspaceGitBranch(base string) string {
|
||||
return branch
|
||||
}
|
||||
|
||||
headCmd := workspaceGitCommand(ctx, "-C", base, "rev-parse", "--short", "HEAD")
|
||||
raw, err = headCmd.Output()
|
||||
raw, err = workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "rev-parse", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -45,6 +45,36 @@ func TestWorkspaceGitBranch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceGitBranchReflectsImmediateCheckout(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(orig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
repo := t.TempDir()
|
||||
if err := os.Chdir(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, "init")
|
||||
runGit(t, "checkout", "-b", "feature/one")
|
||||
|
||||
if got := workspaceGitBranch(repo); got != "feature/one" {
|
||||
t.Fatalf("branch before checkout = %q, want feature/one", got)
|
||||
}
|
||||
runGit(t, "checkout", "-b", "feature/two")
|
||||
if got := workspaceGitBranch(repo); got != "feature/two" {
|
||||
t.Fatalf("branch after checkout = %q, want feature/two", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceGitBranchDetachedHead(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
|
||||
@@ -7,6 +7,10 @@ here. Snapshot-based and aligned with Claude Code. An optional git-backed mode i
|
||||
the remaining (lower-priority) follow-up. Tracks the most requested missing
|
||||
capability from v1 — an edit safety net / undo.
|
||||
|
||||
This document describes rewind snapshots. For the autonomous-run rule about when
|
||||
the agent should pause and ask the user, see
|
||||
[`TASK_CONTRACT.md`](TASK_CONTRACT.md).
|
||||
|
||||
## Goal
|
||||
|
||||
Let a user rewind a session to a previous point and restore **code**,
|
||||
|
||||
@@ -52,6 +52,13 @@ Reasonix 桌面端输入框左下角的“协作方式”菜单包含三类常
|
||||
“不要原地打转”。这类目标会自动进入 AutoResearch 策略,把状态写到
|
||||
`.reasonix/autoresearch/<task-id>/`。
|
||||
|
||||
### 推荐目标写法
|
||||
|
||||
复杂目标可以写成一份任务合约:Context、Request、Output format、Constraints、Pause policy。
|
||||
Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆或对外可见操作、任务范围变化,
|
||||
或需要你提供信息,否则会继续完成任务后再汇报。完整模板见
|
||||
[`TASK_CONTRACT.zh-CN.md`](./TASK_CONTRACT.zh-CN.md)。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。
|
||||
|
||||
+20
-4
@@ -15,6 +15,17 @@ location.
|
||||
Set `REASONIX_HOME` to override Reasonix home for tests, CI, or portable
|
||||
installations. Normal users should not need it.
|
||||
|
||||
When `REASONIX_HOME` is set, the runtime is fully self-contained: all
|
||||
configuration, state, cache, and data live under that directory tree. Legacy
|
||||
migration, OS-home convention directory scanning, and all other fallback paths
|
||||
are skipped so no data leaks in from a system-wide production install.
|
||||
|
||||
Advanced test and portable setups may set `REASONIX_STATE_HOME` to move runtime
|
||||
state such as sessions, archives, and memory. It does not move global config or
|
||||
provider credentials: those remain under `REASONIX_HOME`. If an older build wrote
|
||||
provider keys to `REASONIX_STATE_HOME/.env`, Reasonix imports those keys
|
||||
non-destructively when `<Reasonix home>/.env` is missing them.
|
||||
|
||||
## What Lives There
|
||||
|
||||
| Data | Path |
|
||||
@@ -26,9 +37,12 @@ installations. Normal users should not need it.
|
||||
| Global skills | `<Reasonix home>/skills/` |
|
||||
| Global hooks | `<Reasonix home>/settings.json` |
|
||||
| Hook trust store | `<Reasonix home>/trust.json` |
|
||||
| Sessions | `<Reasonix home>/sessions/` |
|
||||
| Archives | `<Reasonix home>/archive/` |
|
||||
| Memory | `<Reasonix home>/memory/` and `<Reasonix home>/projects/` |
|
||||
| Sessions | `<state root>/sessions/` |
|
||||
| Archives | `<state root>/archive/` |
|
||||
| Memory | `<state root>/memory/` and `<state root>/projects/` |
|
||||
|
||||
`<state root>` defaults to `<Reasonix home>`. It only differs when
|
||||
`REASONIX_STATE_HOME` is set.
|
||||
|
||||
The global user config is named `config.toml`. Project-local config files keep
|
||||
the name `reasonix.toml`. If someone says "global reasonix.toml", they usually
|
||||
@@ -165,7 +179,9 @@ into the process environment, and Reasonix control variables such as
|
||||
Caches remain in the OS cache directory, for example
|
||||
`~/Library/Caches/reasonix` on macOS, `$XDG_CACHE_HOME/reasonix` or
|
||||
`~/.cache/reasonix` on Linux, and `%LOCALAPPDATA%\reasonix\cache` on Windows.
|
||||
Set `REASONIX_CACHE_HOME` to override the cache root.
|
||||
Set `REASONIX_CACHE_HOME` to override the cache root. When `REASONIX_HOME` is
|
||||
set, the cache is placed under `$REASONIX_HOME/cache` (unless
|
||||
`REASONIX_CACHE_HOME` is also set, which takes precedence).
|
||||
|
||||
## Config Priority
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
|
||||
可以设置 `REASONIX_HOME` 覆盖 Reasonix home,主要用于测试、CI 或便携安装。普通用户通常不需要设置。
|
||||
|
||||
设置 `REASONIX_HOME` 后,运行时会变成完整自包含模式:配置、状态、缓存和数据都会位于该目录树下。
|
||||
Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳过,避免从系统级正式安装带入或写回数据。
|
||||
|
||||
高级测试或便携安装可以设置 `REASONIX_STATE_HOME` 来移动 sessions、archive、memory 等运行状态。
|
||||
它不会移动全局配置或 provider 凭据;这些仍然位于 `REASONIX_HOME` 下。如果旧版本曾把 provider key
|
||||
写到 `REASONIX_STATE_HOME/.env`,Reasonix 会在 `<Reasonix home>/.env` 缺少对应 key 时非破坏性导入。
|
||||
|
||||
## 目录内容
|
||||
|
||||
| 数据 | 路径 |
|
||||
@@ -23,9 +30,12 @@
|
||||
| 全局 skills | `<Reasonix home>/skills/` |
|
||||
| 全局 hooks | `<Reasonix home>/settings.json` |
|
||||
| hooks 信任状态 | `<Reasonix home>/trust.json` |
|
||||
| 会话 | `<Reasonix home>/sessions/` |
|
||||
| 归档 | `<Reasonix home>/archive/` |
|
||||
| 记忆 | `<Reasonix home>/memory/` 与 `<Reasonix home>/projects/` |
|
||||
| 会话 | `<state root>/sessions/` |
|
||||
| 归档 | `<state root>/archive/` |
|
||||
| 记忆 | `<state root>/memory/` 与 `<state root>/projects/` |
|
||||
|
||||
`<state root>` 默认等于 `<Reasonix home>`;只有设置 `REASONIX_STATE_HOME`
|
||||
时才会不同。
|
||||
|
||||
全局用户配置文件名是 `config.toml`。项目本地配置文件仍叫 `reasonix.toml`。
|
||||
如果有人说“全局 reasonix.toml”,通常指的是 `<Reasonix home>/config.toml`。
|
||||
@@ -135,6 +145,8 @@ Provider 请求只会从这个全局 `.env` 解析 key。项目 `.env`、home `.
|
||||
缓存仍放在系统缓存目录,例如 macOS 的 `~/Library/Caches/reasonix`、
|
||||
Linux 的 `$XDG_CACHE_HOME/reasonix` 或 `~/.cache/reasonix`、Windows 的
|
||||
`%LOCALAPPDATA%\reasonix\cache`。可以设置 `REASONIX_CACHE_HOME` 覆盖缓存根目录。
|
||||
设置 `REASONIX_HOME` 后,缓存会放在 `$REASONIX_HOME/cache`;如果同时设置
|
||||
`REASONIX_CACHE_HOME`,后者优先。
|
||||
|
||||
## 配置优先级
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ Reasonix 的 Goal 模式(`/goal`)在 v1.9+ 中增加了来自 Oh-My-OpenAgen
|
||||
- 需要完整测试覆盖的任务
|
||||
- 多步骤复杂流程
|
||||
|
||||
### 任务合约
|
||||
|
||||
复杂目标可以直接写成 Context / Request / Output format / Constraints /
|
||||
Pause policy。Goal 模式会把这些段落当作执行边界:满足请求、输出格式、约束和必要验证后才结束;
|
||||
除非下一步涉及不可逆或对外可见操作、范围变化,或必须由用户提供信息,否则继续采用合理默认值推进。
|
||||
|
||||
### 并行子任务
|
||||
|
||||
```bash
|
||||
|
||||
+35
-7
@@ -16,6 +16,7 @@
|
||||
- [Serve web frontend](#serve-web-frontend)
|
||||
- [Configuration paths](./CONFIG_PATHS.md)
|
||||
- [Reasoning language](./REASONING_LANGUAGE.md)
|
||||
- [Task contracts and pause policy](./TASK_CONTRACT.md)
|
||||
- [Custom OpenAI-compatible providers](#custom-openai-compatible-providers)
|
||||
- [Desktop hooks](#desktop-hooks)
|
||||
- [Keyboard shortcuts](#keyboard-shortcuts)
|
||||
@@ -479,13 +480,33 @@ out. `forbid_read` optionally hides sensitive directories from the agent's
|
||||
read/list/search tools; use absolute paths or `${HOME}` / `${VAR}` references,
|
||||
not `~`, because config expansion is environment-variable based. `bash` is
|
||||
itself jailed by default when an OS sandbox is available (`[sandbox] bash`,
|
||||
Seatbelt on macOS and bubblewrap on Linux): commands may write only those same
|
||||
roots (plus temp and toolchain caches), cannot read configured `forbid_read`
|
||||
roots while the OS sandbox is active, and reach the network only when
|
||||
`[sandbox] network` is set. When no OS sandbox is available, `bash = "enforce"`
|
||||
refuses bash execution instead of running unconfined (see
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt and
|
||||
broader OS support still to come).
|
||||
Seatbelt on macOS, bubblewrap on Linux, and a native helper on Windows):
|
||||
commands may write only those same roots plus platform-specific command
|
||||
temp/cache roots, cannot read configured `forbid_read` roots while the OS
|
||||
sandbox is active, and reach the network only when `[sandbox] network` is set.
|
||||
The native Windows helper delegates the low-level isolation to
|
||||
`github.com/SivanCola/windows-sandbox`, which uses AppContainer for read-only
|
||||
commands and a low-integrity token for writable commands, temporarily grants
|
||||
access to the workspace, a per-command temp root, and the target executable,
|
||||
applies deny ACEs for `forbid_read` (files as well as directories), snapshots
|
||||
touched DACLs before editing them, and restores those snapshots best-effort
|
||||
after the command exits. Concurrent commands touching the same workspace are
|
||||
serialized so their ACL edits cannot corrupt each other, and residue from a
|
||||
force-killed command (a lingering low-integrity label or `forbid_read` deny) is
|
||||
cleaned up by the next run. Because a writable command runs under a
|
||||
low-integrity token, it can still write the few locations Windows leaves
|
||||
writable to any low-integrity process (for example `%USERPROFILE%\AppData\LocalLow`)
|
||||
in addition to the configured roots; the workspace boundary and `forbid_read`
|
||||
denials still hold. Read-only AppContainer commands omit network capabilities
|
||||
when networking is disabled; writable Windows commands fail closed when
|
||||
`[sandbox] network = false`.
|
||||
When no OS sandbox backend is available, `bash = "enforce"` refuses bash
|
||||
execution instead of running unconfined. Install the platform sandbox backend
|
||||
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
|
||||
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
|
||||
behavior (see
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt
|
||||
and optional elevated Windows hardening still to come).
|
||||
|
||||
## Plugins (MCP)
|
||||
|
||||
@@ -659,6 +680,13 @@ skill in Settings -> Skills or the slash menu. If an ordinary chat prompt has a
|
||||
very strong long-horizon signal, the host also upgrades it into the equivalent
|
||||
of `/goal --research <original prompt>`.
|
||||
|
||||
For complex work, write the objective as a
|
||||
[task contract](./TASK_CONTRACT.md): Context, Request, Output format,
|
||||
Constraints, and Pause policy. Goal mode treats those sections as the boundary
|
||||
for autonomous work. It keeps going with sensible defaults unless the next step
|
||||
requires an irreversible or externally visible operation, a scope change, or
|
||||
information only the user can provide.
|
||||
|
||||
AutoResearch is enabled for goals with strong signals such as "keep
|
||||
researching", "long-running", "thoroughly", "debug until the root cause is
|
||||
clear", "do not spin", "run experiments", "verify repeatedly", or "turn this
|
||||
|
||||
+19
-3
@@ -16,6 +16,7 @@
|
||||
- [Serve Web 前端](#serve-web-前端)
|
||||
- [配置路径](./CONFIG_PATHS.zh-CN.md)
|
||||
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
|
||||
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
|
||||
- [自定义 OpenAI-compatible provider](#自定义-openai-compatible-provider)
|
||||
- [桌面端 Hooks](./DESKTOP_HOOKS.zh-CN.md)
|
||||
- [快捷键](#快捷键)
|
||||
@@ -391,9 +392,20 @@ CJK 双宽字符,造成视觉错位。想保留旧的终端块状光标可设
|
||||
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
|
||||
打洞越界。`forbid_read` 可选地隐藏敏感目录,使 agent 的读文件、列目录和搜索工具不能读取或列出它们;
|
||||
建议使用绝对路径或 `${HOME}` / `${VAR}`,不要写 `~`,因为配置只做环境变量展开。
|
||||
`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):命令只能写这些 root(外加临时目录与工具链缓存),
|
||||
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网;
|
||||
其它平台在没有可用 OS 沙盒时会回退为不沙盒运行(越界问一次与 Linux 支持见
|
||||
`bash` 本身默认进 OS 沙盒(`[sandbox] bash`:macOS 使用 Seatbelt,Linux 使用 bubblewrap,
|
||||
原生 Windows 使用 native helper):命令只能写这些 root(外加平台按命令提供的临时/缓存 root),
|
||||
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网。
|
||||
原生 Windows helper 把底层隔离委托给 `github.com/SivanCola/windows-sandbox`:
|
||||
只读命令使用 AppContainer,可写命令使用 low-integrity token;它会临时授予
|
||||
workspace、每次命令专用 temp root 和目标可执行文件的访问权,对 `forbid_read`
|
||||
(文件和目录皆可)临时添加 deny ACE,修改前记录被触碰目录的 DACL,命令结束后尽力恢复。
|
||||
作用于同一 workspace 的并发命令会被串行化,避免各自的 ACL 修改互相破坏;被强杀命令
|
||||
残留的 low-integrity 标签或 `forbid_read` deny ACE 会由下一次运行清理。由于可写命令跑在
|
||||
low-integrity token 下,除配置的 root 外它仍能写入 Windows 对任何 low-integrity 进程开放的
|
||||
少数位置(例如 `%USERPROFILE%\AppData\LocalLow`),但 workspace 边界与 `forbid_read`
|
||||
拒绝依然有效。只读 AppContainer 命令在关闭网络时不给 network capability;可写 Windows 命令遇到
|
||||
`[sandbox] network = false` 时会 fail closed。没有可用 OS 沙盒时,`bash = "enforce"` 会拒绝 bash 执行,不会无沙盒运行
|
||||
(越界询问与可选的 Windows elevated 加固见
|
||||
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope))。
|
||||
|
||||
## 插件(MCP)
|
||||
@@ -529,6 +541,10 @@ Goal 是长期目标的统一运行机制。普通 `/goal` 继续走轻量 Goal
|
||||
Settings -> Skills 或斜杠菜单里。普通聊天输入如果命中很强的长周期信号,也会被 host 自动
|
||||
升级为等价的 `/goal --research <原输入>`。
|
||||
|
||||
复杂任务建议把目标写成[任务合约](./TASK_CONTRACT.zh-CN.md):Context、Request、
|
||||
Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当作自主执行的边界;
|
||||
除非下一步需要不可逆或对外可见操作、任务范围变化,或必须由用户提供信息,否则会继续采用合理默认值推进,并在最后汇报假设与结果。
|
||||
|
||||
AutoResearch 会在这些目标里自动启用:包含“持续”“长期”“彻底”“直到根因明确”“多轮排查”
|
||||
“不要原地打转”“完整方案”“跑实验”“反复验证”“系统性研究”等强信号;或者目标同时包含
|
||||
研究/排查、实现/修复、验证/测试、优化/文档/发布等多个阶段;或者用户明确给出
|
||||
|
||||
+43
-10
@@ -362,7 +362,17 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
model reports completion, repeats the same blocked state three times, the user
|
||||
stops it, or the safety continuation limit is reached. Blocked-state matching
|
||||
is normalized for casing, whitespace, and punctuation so minor wording drift
|
||||
does not reset the audit; restarting a goal begins a fresh blocked audit.
|
||||
does not reset the audit; restarting a goal begins a fresh blocked audit. A
|
||||
goal is treated as a task contract: if the objective includes Context,
|
||||
Request, Output format, Constraints, or Pause policy sections, those sections
|
||||
define the autonomous work boundary. When they are absent, the model infers a
|
||||
lightweight contract from the conversation and workspace. The injected goal
|
||||
block tells the model to pause only for irreversible or externally visible
|
||||
operations, scope changes, or information only the user can provide; ordinary
|
||||
uncertainty should be handled with sensible defaults and reported as an
|
||||
assumption. Completion requires the concrete request, output format,
|
||||
constraints, and relevant verification expectations to be satisfied or
|
||||
explicitly reported as unverified.
|
||||
Goals that look like long-horizon research, debugging, optimization, or
|
||||
implementation work automatically add an AutoResearch protocol to the same
|
||||
transient active-goal user block. AutoResearch is a Goal strategy, not a
|
||||
@@ -646,12 +656,34 @@ not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansio
|
||||
and should be absolute, or use `${HOME}` for home-relative secrets such as
|
||||
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
|
||||
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS, bubblewrap on
|
||||
Linux): each command is allowed to write only the same roots (+ temp and
|
||||
toolchain caches), denied reads under `forbid_read`, and allowed to reach the
|
||||
network only when `network = true`.
|
||||
Linux, and a native helper on Windows): each command is allowed to write only
|
||||
the same roots plus platform-specific command temp/cache roots, denied reads
|
||||
under `forbid_read`, and allowed to reach the network only when
|
||||
`network = true`.
|
||||
The native Windows helper delegates the low-level isolation to
|
||||
`github.com/SivanCola/windows-sandbox`: AppContainer for read-only commands and
|
||||
a low-integrity token for writable commands, with temporary ACL grants for
|
||||
writable roots and tool executables, a per-command temp root instead of mutating
|
||||
the global Temp directory, temporary deny ACEs for `forbid_read` (files and
|
||||
directories), best-effort restoration from pre-run DACL snapshots for touched
|
||||
directories, and a kill-on-close Job Object. Because the sandbox works by
|
||||
temporarily mutating shared-path ACLs and integrity labels, concurrent commands
|
||||
against the same root are serialized with a per-root lock, and residue from a
|
||||
force-killed command (a lingering low-integrity label or `forbid_read` deny ACE)
|
||||
is swept by the next run so a crash cannot durably lower a workspace's integrity
|
||||
or lock the user out of a `forbid_read` path. A writable command runs under a
|
||||
low-integrity token, so beyond the configured roots it retains write access to
|
||||
the narrow set of locations Windows leaves writable to any low-integrity process
|
||||
(e.g. `AppData\LocalLow`); the workspace boundary and `forbid_read` denials are
|
||||
unaffected. Read-only AppContainer commands omit network capabilities when
|
||||
networking is disabled; writable Windows commands fail closed when
|
||||
`network = false` because the low-integrity token does not provide a reliable
|
||||
per-process network block without elevated firewall/WFP setup.
|
||||
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
|
||||
instead of running unconfined. The escape-prompt and broader OS support are Phase
|
||||
1's remainder (§9).
|
||||
instead of running unconfined. Install the platform sandbox backend
|
||||
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
|
||||
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
|
||||
behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9).
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
@@ -679,14 +711,15 @@ instead of running unconfined. The escape-prompt and broader OS support are Phas
|
||||
|
||||
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
|
||||
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
|
||||
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
|
||||
macOS, bubblewrap on Linux, and a native Windows helper ship, on by default
|
||||
when available** (see §5).
|
||||
Remaining: (a)
|
||||
the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
|
||||
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
|
||||
command just fails and the model adapts), which completes the "allow inside the
|
||||
box, prompt at its edge" model; (b) broader OS sandbox support beyond current
|
||||
Seatbelt/bubblewrap hosts. Shells out to OS tooling so the binary stays
|
||||
dependency-free. With this in
|
||||
box, prompt at its edge" model; (b) an optional elevated Windows backend with a
|
||||
dedicated sandbox user for enterprise hardening. Shells out to OS tooling so
|
||||
the binary stays dependency-free. With this in
|
||||
place, "always allow" rule persistence becomes optional rather than load-bearing.
|
||||
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
|
||||
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Task Contracts and Pause Policy
|
||||
|
||||
<a href="./GUIDE.md">Guide</a>
|
||||
·
|
||||
<a href="./TASK_CONTRACT.zh-CN.md">Simplified Chinese</a>
|
||||
|
||||
Reasonix works best when nontrivial work is described as a **task contract**:
|
||||
what the work is for, what action is requested, how the result should be
|
||||
delivered, what boundaries must be preserved, and when the agent should pause.
|
||||
Some prompt templates call the last section "Checkpoint"; Reasonix documents it
|
||||
as "Pause policy" to avoid confusion with Checkpoints/Rewind snapshots.
|
||||
|
||||
This is intentionally not a larger role prompt. Strong coding agents usually do
|
||||
not need step-by-step thinking instructions. They need clear boundaries and
|
||||
acceptance criteria.
|
||||
|
||||
## Template
|
||||
|
||||
```text
|
||||
Context:
|
||||
I am working on [larger task].
|
||||
The target audience/user is [who].
|
||||
This result should help them [achieve what outcome].
|
||||
|
||||
Request:
|
||||
Please complete [one clear action].
|
||||
|
||||
Output format:
|
||||
Return the result as [specific structure].
|
||||
It must include [required sections].
|
||||
Keep it within [length or scope].
|
||||
|
||||
Constraints:
|
||||
Do not [bad assumption].
|
||||
Do not [out-of-scope content].
|
||||
Do not [low-quality output shape].
|
||||
If information is missing, mark uncertainty explicitly.
|
||||
|
||||
Pause policy:
|
||||
Unless the next step involves an irreversible or externally visible operation,
|
||||
a scope change, or information only I can provide, keep working and report back
|
||||
after the task is complete.
|
||||
```
|
||||
|
||||
## How Reasonix Uses It
|
||||
|
||||
- **Normal chat** can use the template directly for one-off work.
|
||||
- **Goal mode** treats the goal as a task contract and keeps working until the
|
||||
request, output format, constraints, and verification expectations are
|
||||
satisfied.
|
||||
- **Plan mode** is still the right choice when you want a read-only plan before
|
||||
edits or side effects.
|
||||
- **Tool approval** remains separate: file writes, shell commands, publishing,
|
||||
credentials, and external effects still follow the configured approval policy.
|
||||
- **Checkpoints/Rewind** are file and conversation snapshots. The task contract's
|
||||
pause policy is about when the agent should ask the user before continuing.
|
||||
|
||||
The Goal-mode task contract rides the provider-visible user turn. It does not
|
||||
rewrite the cache-stable system prompt, memory prefix, or tool schemas.
|
||||
|
||||
## Example
|
||||
|
||||
```text
|
||||
/goal Context:
|
||||
I am improving the desktop composer.
|
||||
The target user is someone doing repeated code review sessions.
|
||||
This should help them avoid accidental interruptions.
|
||||
|
||||
Request:
|
||||
Make the slash-command menu keep keyboard focus while suggestions are open.
|
||||
|
||||
Output format:
|
||||
After implementation, summarize changed files and verification results.
|
||||
|
||||
Constraints:
|
||||
Do not change the Wails JSON contract.
|
||||
Do not refactor unrelated composer state.
|
||||
If browser verification cannot run, say why.
|
||||
|
||||
Pause policy:
|
||||
Unless the next step requires a product decision, a public push, or credentials,
|
||||
continue through implementation and verification before reporting back.
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# 任务合约与暂停策略
|
||||
|
||||
<a href="./GUIDE.zh-CN.md">使用指南</a>
|
||||
·
|
||||
<a href="./TASK_CONTRACT.md">English</a>
|
||||
|
||||
复杂任务最适合写成一份**任务合约**:背景是什么、要完成什么动作、结果怎么交付、哪些边界不能越过,以及什么时候才需要暂停问用户。
|
||||
有些 prompt 模板把最后一段叫 “Checkpoint”;Reasonix 文档里叫 “Pause policy”,避免和 Checkpoints/Rewind 快照混淆。
|
||||
|
||||
这不是更长的角色设定。更强的 coding agent 通常不需要用户教它一步步思考;它更需要清楚的任务边界和验收标准。
|
||||
|
||||
## 模板
|
||||
|
||||
```text
|
||||
Context:
|
||||
我正在做 [大任务]。
|
||||
目标对象是 [谁]。
|
||||
这个结果要帮助他们 [达成什么结果]。
|
||||
|
||||
Request:
|
||||
请完成 [一个明确动作]。
|
||||
|
||||
Output format:
|
||||
请按照 [具体结构] 输出。
|
||||
必须包含 [必要模块]。
|
||||
不要超过 [长度/范围]。
|
||||
|
||||
Constraints:
|
||||
不要 [错误假设]。
|
||||
不要 [越界内容]。
|
||||
不要 [低质量输出形式]。
|
||||
如果 [信息不足],请明确标注不确定性。
|
||||
|
||||
Pause policy:
|
||||
除非下一步涉及不可逆或对外可见操作、任务范围变化,或需要我提供信息,否则请继续完成任务后再汇报。
|
||||
```
|
||||
|
||||
## Reasonix 如何使用它
|
||||
|
||||
- **普通聊天**可以直接粘贴这份模板,适合一次性任务。
|
||||
- **Goal 模式**会把目标当作任务合约持续推进,直到 request、output format、constraints 和必要验证都满足。
|
||||
- **计划模式**仍然适合“先只读分析、确认方案后再改”的场景。
|
||||
- **工具审批**仍然独立生效:写文件、跑 shell、发布、凭证、外部副作用都会继续遵守配置的审批策略。
|
||||
- **Checkpoints/Rewind** 是代码和会话快照;这里的暂停策略只描述 agent 什么时候应该停下来问用户。
|
||||
|
||||
Goal 模式里的任务合约会随 user turn 注入 provider 可见上下文,不会改写 cache-stable system prompt、memory prefix 或 tool schema。
|
||||
|
||||
## 示例
|
||||
|
||||
```text
|
||||
/goal Context:
|
||||
我正在改进桌面端输入框。
|
||||
目标对象是需要连续做代码审查的用户。
|
||||
这个结果要帮助他们避免被补全菜单打断输入。
|
||||
|
||||
Request:
|
||||
修复 slash-command 菜单打开时键盘焦点丢失的问题。
|
||||
|
||||
Output format:
|
||||
完成后按“改了什么 / 验证结果 / 剩余风险”汇报。
|
||||
|
||||
Constraints:
|
||||
不要改变 Wails JSON 合约。
|
||||
不要顺手重构无关的 composer 状态。
|
||||
如果无法跑浏览器验证,请说明原因。
|
||||
|
||||
Pause policy:
|
||||
除非下一步需要产品判断、公开 push 或凭证,否则继续完成实现和验证后再汇报。
|
||||
```
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb h1:au4YwGV3d5zAB6CcboeNocRLwFoE7XGxnx2RYI78iv4=
|
||||
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb/go.mod h1:qL+ewlVGGeJLO97u+7E4K20gLu9oST/lc8FaGo2JJSw=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/permission"
|
||||
"reasonix/internal/provider"
|
||||
@@ -330,11 +331,27 @@ func (s *updateSink) requestAskQuestion(ctx context.Context, askID string, q eve
|
||||
}
|
||||
|
||||
func approvalSessionOptionName(tool, subject string) string {
|
||||
if tool == control.SandboxEscapeApprovalTool {
|
||||
return "Use real environment for this session"
|
||||
}
|
||||
sessionRule := permission.SessionGrantRuleForScope(tool, subject)
|
||||
return "Allow " + sessionRule + " for this session"
|
||||
}
|
||||
|
||||
func approvalOptions(tool, subject string) []PermissionOption {
|
||||
if control.RequiresFreshHumanApprovalTool(tool) {
|
||||
if tool == control.SandboxEscapeApprovalTool {
|
||||
return []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
{OptionID: string(OptAllowAlways), Name: approvalSessionOptionName(tool, subject), Kind: OptAllowAlways},
|
||||
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
|
||||
}
|
||||
}
|
||||
return []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
|
||||
}
|
||||
}
|
||||
allowSessionName := approvalSessionOptionName(tool, subject)
|
||||
options := []PermissionOption{
|
||||
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
|
||||
@@ -383,6 +400,8 @@ func toolKindFor(name string) string {
|
||||
return "edit"
|
||||
case "bash":
|
||||
return "execute"
|
||||
case control.SandboxEscapeApprovalTool:
|
||||
return "execute"
|
||||
}
|
||||
n := strings.ToLower(name)
|
||||
switch {
|
||||
|
||||
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user