Compare commits

...
173 Commits
Author SHA1 Message Date
SivanCola 3b677cf383 Merge pull request #6137 from SivanCola/fix/auto-compound-bash-approval
fix auto approval for compound bash / 修复复合 bash 自动审批
2026-07-07 12:02:12 +08:00
SivanCola 27962e7d95 Merge pull request #6136 from SivanCola/fix/ready-meta-reconcile
Reconcile startup readiness metadata / 对账启动 ready 元数据
2026-07-07 11:58:34 +08:00
merge-order-check bea5daa725 fix auto approval for compound bash 2026-07-07 11:54:17 +08:00
merge-order-check 6dd0b063e8 Reconcile startup readiness metadata 2026-07-07 11:43:56 +08:00
SivanCola 96f850438c Merge pull request #6134 from SivanCola/fix/retry-startup-lease-recovery
Recover startup session lease retries / 恢复启动会话锁重试
2026-07-07 11:27:50 +08:00
merge-order-check 2edf7fdae0 Recover tabs after startup session lease release 2026-07-07 11:00:44 +08:00
SivanCola f8bef2ac7d Merge pull request #6131 from SivanCola/fix/heartbeat-external-edit-adoption
Honor external edits to heartbeat-tasks.json / 心跳任务文件的外部编辑不再丢失
2026-07-07 07:13:08 +08:00
SivanCola c92b7b528c Merge pull request #6123 from SivanCola/fix/recovery-copy-ux-and-session-data-guard
Fix recovery-copy UX and guard agent writes to session data / 修复冲突副本体验并防护 agent 直写会话数据
2026-07-07 07:02:42 +08:00
merge-order-check da6edfcc6f Honor external edits to heartbeat-tasks.json
heartbeat-tasks.json is documented as human- and AI-editable (file
header + the heartbeat panel tip), but the engine loaded it once at
startup and then treated its in-memory copy as the source of truth:

- an externally added task was never scheduled until a manual Refresh
  or an app restart, and
- any run-state save (mergeRunUpdatesLocked writes the full list after
  a task fires) silently rolled the external edit back — the same
  "my write did not take" shape the session-data guard exists for,
  except here the product explicitly invites the edit.

Two changes make the contract real:

- mergeRunUpdatesLocked now rebases onto the on-disk list before its
  full-list save: the engine owns only the run-state fields (TopicID,
  LastRunAt, CreatedAt backfill), so external additions, edits, and
  deletions survive the save. A missing/unreadable file falls back to
  the in-memory list, preserving the historical behavior (and the
  existing concurrent-edit test).
- tick() probes the config mtime and adopts external edits before
  scheduling, so an agent-added task runs on the next tick. The engine
  records the mtime after its own writes to avoid re-reading them.
2026-07-07 04:06:26 +08:00
merge-order-check f2b3f40901 Deduplicate recovery conflict notices 2026-07-07 03:59:06 +08:00
merge-order-check 4b00b175e5 Keep heartbeat-tasks.json agent-editable
Review finding: heartbeat-tasks.json is a documented human/AI-editable
contract (desktop/heartbeat.go header; the heartbeat panel tip says
"AI agents can also edit heartbeat-tasks.json"), so the runtime-ledger
guard must not hard-refuse it — that broke the advertised flow of
asking the agent to create or update heartbeat tasks. Drop it from the
deny list and the bash hint needles, and pin the contract with a
positive regression so the guard can never swallow it again.
2026-07-07 03:43:49 +08:00
merge-order-check dfbc998926 Narrow the bash hint's lexical blind spots
Follow-up sweep on the remaining lexical gaps of the session-data
bash warning (write tools were never affected — relative paths get
absolutized before the hard deny):

- $HOME/${HOME} and %APPDATA%/$env:APPDATA spellings of the guarded
  trees now match, alongside the existing absolute and ~/ forms.
- The hint is workDir-aware: the desktop Global workspace lives at
  <state root>/global-workspace, so ../sessions reached the stores
  without an absolute path in the command text. Relative forms are
  matched from a state-root workDir, and a workDir already inside a
  guarded store warns on every command.
- workDir goes through realPath so a symlinked cwd (or /var vs
  /private/var on macOS) cannot dodge the workDir checks.

Remaining lexical limits (paths hidden in script files, runtime
variable concatenation) are inherent to text matching; the writer-tool
hard deny and the conflict-copy CAS remain the backstops.
2026-07-07 03:35:31 +08:00
merge-order-check 8dd8666194 Close case-fold and ledger gaps in the session-data guard
Same-shape sweep after the initial guard landed:

- Case-variant bypass (confirmed by repro): EvalSymlinks does not
  normalize case, and default macOS/Windows volumes are
  case-insensitive, so ~/.reasonix/SESSIONS/x.jsonl reached the real
  store while sliding past the deny check — the same failure shape as
  the Windows lease-key case split (#6023). Deny-side checks (the
  guard and forbid_read's confineRead) now fold case on those
  platforms; allow-side confine keeps exact matching, since folding an
  allow rule on a case-sensitive filesystem would wave a genuinely
  different directory through.
- State-root-direct runtime ledgers (desktop-tabs.json and friends,
  heartbeat-tasks.json, metrics/crash-pending.json) are rewritten
  wholesale by the app, so agent edits vanish the same way session
  edits do — the "index files" from the user report. They join the
  guard; config.toml/credentials/skills stay writable on request.
- reasonix review built its subagent bash from the init template,
  skipping the guard; it now carries the same session-data warning.
2026-07-07 03:35:31 +08:00
merge-order-check 82f39271ff Fix recovery-copy UX and guard agent writes to session data
Recovery-copy fixes (user-reported screenshots):
- agent:ready could reuse an in-flight hydrate and keep a stale
  ready=false tab meta, locking the composer until an unrelated
  settings reload re-emitted ready. The ready handler now refreshes
  the active tab metadata from ListTabs first (preserving cached
  history), so the send gate opens as soon as the backend is ready.
- Recovery notices no longer expose internal recovery-branch ids;
  the frontend maps both the new and the legacy English notice
  wording to localized, user-facing text so old transcripts render
  friendly too. The recovery banner no longer builds a tooltip from
  digest/parent-id internals, and the now-dead locale keys are gone.

Session-data write guard (root cause of the token-burn loop):
- An agent asked to "fix Reasonix" from inside Reasonix would write
  the app's own session JSONL/meta files via bash or file tools. The
  runtime is the only writer of those stores; external edits race its
  autosaves, fork endless conflict copies, and look to the agent like
  failed writes, so it retries in a loop.
- New SessionDataGuard rejects writer-tool targets under
  <state root>/sessions and <state root>/projects/*/sessions even when
  workspace write roots cover them (e.g. a home-directory workspace);
  explicit [sandbox] allow_write entries remain a sanctioned escape
  hatch. bash appends a warning when a command references those trees
  (never blocks: diagnostic reads are legitimate) so the retry loop is
  broken at the model level.
- Wired through ConfineWriters/ConfineBash/Workspace at the boot and
  ACP composition roots; zero-value guard keeps init-registered tools
  unconfined as before.
2026-07-07 03:35:31 +08:00
SivanCola 9cbe684a5a Merge pull request #6128 from SivanCola/fix/stepfun-base-url
Fix StepFun preset base URLs
2026-07-07 03:31:23 +08:00
SivanCola 3a5b4d9fef Merge pull request #6118 from SivanCola/ci/release-npm-freshness-check
ci(release): catch missing stable npm tags without failing in-flight publishes
2026-07-07 03:30:02 +08:00
SivanCola 1c17637267 Merge pull request #6126 from SivanCola/fix/sidecar-reconcile-and-doctor-out
Migrate diagnostic sidecars through overlong-name reconciliation / 超长名整理时迁移诊断 sidecar
2026-07-07 03:25:11 +08:00
merge-order-check ead3fec4fd Migrate legacy StepFun preset URLs 2026-07-07 03:22:57 +08:00
SivanCola 2dcc7fd5a8 Merge pull request #6125 from SivanCola/refactor/session-owned-rewrite-baseline
Move the persisted-rewrite baseline into agent.Session / 将 rewrite 持久化基线内聚进 Session
2026-07-07 03:15:45 +08:00
SivanCola d23d347897 ci(release): distinguish missing npm tag from an in-flight npm publish
Review catch: the freshness check raced normal releases. The runbook
pushes v*/npm-v*/desktop-v* together, the two workflows wait on the
release environment independently, and npm dist-tags propagate
asynchronously — so a single read of dist-tags.latest could fail the
CLI release while the npm publish was merely awaiting approval or
propagating, telling the operator to push a tag that was already
pushed.

Split the check into the two states the review named:
- npm-v<version> tag missing (git ls-remote) -> hard fail. This is the
  #5822 gap: nobody released to npm at all.
- tag pushed but latest lagging -> poll 6x10s (matching release-npm's
  own verify cadence), then WARN and pass: the publish may still be
  awaiting its environment approval, and release-npm.yml's verify step
  owns asserting the dist-tag lands.

RELEASING.md wording updated to match (fails on missing tag; pending
publish only warns).
2026-07-07 03:13:53 +08:00
merge-order-check 85833b9c07 Fix StepFun preset base URLs 2026-07-07 03:09:34 +08:00
merge-order-checkandSivanCola 279ec453fe Format session rewrite baseline field
Co-authored-by: SivanCola <[email protected]>
2026-07-07 03:09:05 +08:00
SivanCola 7f445eafaa Merge pull request #6124 from SivanCola/refactor/conflict-outcome-enum
Return an explicit outcome from snapshot conflict recovery / 会话冲突恢复显式返回结果类型
2026-07-07 02:57:19 +08:00
SivanCola 36c293cd80 Merge pull request #6120 from SivanCola/fix/session-conflict-diagnostics
Fix session conflict diagnostics and depth-cap baseline / 修复会话冲突诊断与深度上限基线
2026-07-07 02:52:47 +08:00
SivanCola 8b6fa8c452 docs: update npm channel docs for latest -> 1.x
The migration guide and release runbook still documented the retired
"latest stays pinned to 0.x, v2 lives under @next" policy — the exact
policy that caused #5822. Update both to the current reality:

- MIGRATING.md: bare `npm i -g reasonix` installs the current 1.x
  stable; @next is the rc channel; 0.x stays installable by pinning
  [email protected].2.
- RELEASING.md: channels table shows latest = current 1.x stable with
  next/canary as pre-release buffers; the stable-ship step notes the
  npm-v* tag moves `latest` automatically and must not be skipped
  (release.yml now fails when npm latest lags); drop the manual
  "promote to default install" step — promotion is automatic.
2026-07-07 02:42:00 +08:00
merge-order-check a7b5a0487e Keep diagnostic sidecars out of overlong-name reconciliation and migrate them
The overlong-name pass filtered by a bare .jsonl suffix, so an
.events.jsonl or .conflicts.jsonl whose name crossed the 224-byte bound
(owner stem between 209 and 224 bytes) was renamed into a fake session
with fabricated meta, while the real transcript's rename orphaned both
logs under the retired stem. Filter with store.IsSessionTranscriptName
and carry the event log, event index, and conflict log through
migrateSessionSidecars like the other owned sidecars.

Also accept --out=PATH (equals form) in reasonix doctor session.
2026-07-07 02:34:39 +08:00
merge-order-check d2d37c47ed Move the persisted-rewrite baseline into agent.Session
The controller kept savedRewriteVersion as external bookkeeping about
the executor's session object, so every SetSession had to be paired
with a manual baseline reset and snapshot() had to guess whether
recovery had swapped the object — the guess that broke depth-cap
force-saves (#6120).

Store persistedRewriteVersion on the Session itself: save paths advance
it with the rewriteVersion captured in the same lock window as the
message snapshot (a compaction landing mid-save stays unpersisted, and
a slower save cannot roll it backwards), clones carry it, and swapped
sessions take their baseline with them. snapshotRewriteDecision and
both mark helpers plus their seven call-site pairings collapse into
Session.NeedsRewriteSave.

Behavior fix folded in: installing a clone that still holds an
unpersisted rewrite no longer stamps it persisted-by-construction; the
next snapshot correctly runs as an owned rewrite.
2026-07-07 02:30:42 +08:00
merge-order-check 56667add01 Return an explicit outcome from recoverSnapshotConflict
The caller inferred what recovery did from path and session-pointer
comparisons; the depth-cap baseline bug hid in exactly that inference.
Declare the outcome (adopted / force-saved / forked / dropped) at the
site that performs it, and let replaceSessionAfterCancel log when its
cancel cleanup was dropped instead of silently discarding the result.
2026-07-07 02:19:38 +08:00
merge-order-check 74fa880ef3 Fix deprecated zip SetModTime and warn that doctor session bundles are unredacted 2026-07-07 01:58:43 +08:00
merge-order-check 7a75155823 Fix session conflict diagnostics and depth-cap baseline 2026-07-07 01:17:11 +08:00
SivanCola 92fa78cbc3 ci(release): fail a stable CLI release when npm latest lags behind
v1.17.5 shipped as binaries and Homebrew while the npm `latest`
dist-tag still pointed at 0.53.2 (#5822) — every `npm update -g` /
`pnpm update -g` user was silently downgraded to a months-old version.
The npm line is triggered by its own tag namespace (npm-vX.Y.Z), and
the stable npm tag was simply never pushed: 1.0.0 through 1.17.5 all
went to npm as -rc.1 prereleases under `next`. release-npm.yml's verify
step (added for #5822) only guards runs that happen; nothing caught the
run that DIDN'T.

Add a freshness gate to the stable CLI release: after the public smoke,
compare `npm view reasonix dist-tags.latest` against the version being
released (sort -V). A stale `latest` fails the release with the exact
remediation (push the npm-vX.Y.Z tag, or `npm dist-tag add` for an
already-published version). Prereleases skip — npm latest does not move
on prereleases. A `latest` NEWER than the tag passes, so re-releasing
an older CLI tag is not blocked.
2026-07-07 00:59:43 +08:00
SivanCola 4facd0d6bd Merge pull request #6114 from SivanCola/fix/new-session-resets-goal
fix(control): clear the active goal on session rotation
2026-07-07 00:20:11 +08:00
SivanCola bc1add0b86 fix(desktop): validate the restored tab goal against the session sidecar
Review catch: a typed /new or /clear rotates the session through the
controller slash path and never reaches App.NewSession/ClearSession, so
desktop-tabs.json can keep the pre-rotation goal. On the next restart,
restoreOrBuildTabs read entry.Goal unfiltered and the tab build seeded
it into the rotated session via SetGoal(buildGoal) — resurrecting the
goal the rotation had cleared. (The goal-state filter I pointed at in
the PR description only runs on the runtime-profile path, not startup
restore.)

Restore now validates entry.Goal through runningTabSessionGoal: the
rotation writes a stopped goal-state sidecar onto the fresh path, so a
restart reads stopped → no goal; a tab still pointing at the old
session reads running → goal preserved (resume semantics); a session
without a sidecar keeps the persisted goal (legacy).

TestRestoredTabGoalFollowsSessionGoalState pins the cross-layer
contract with a real controller rotation: rotated path → cleared, old
path → preserved, no sidecar → fallback.
2026-07-06 23:46:34 +08:00
SivanCola 9d00a3716d fix(control): clear the active goal on session rotation
NewSession and ClearSession reset the executor session, planner,
guardian, and checkpoints — but not the goal machine. A running goal's
text (plus its autoresearch runtime block) kept injecting into the
fresh session's first turns, silently steering a conversation the user
had explicitly reset.

Controller: both rotations now ClearGoal() after rebindCheckpoints has
moved the goal-state path to the fresh session, so the cleared
(stopped) state lands on the NEW path while the old session's sidecar —
persisted before the rotation — keeps its running goal for /resume.
This covers every frontend (typed /new, CLI, serve, bot, desktop).

Desktop: the tab's persisted goal copy follows the controller —
App.NewSession and ClearSession clear tab.goal (otherwise the next
rebuild or restart would re-seed the old goal via SetGoal(tab.goal)),
and clearActiveSessionRuntime no longer seeds the destroyed
conversation's goal into the replacement controller. The model/effort/
settings rebuild paths keep the goal: those are same-session rebuilds.

TestSessionRotationClearsActiveGoal pins all four properties: goal
empty and no <active-goal> injection after /new and /clear, the old
session's sidecar keeps the running goal, and the new session's sidecar
does not carry it. Verified it fails without the ClearGoal calls.
2026-07-06 23:18:11 +08:00
SivanCola 221a7645b6 Merge pull request #6106 from SivanCola/fix/windows-bash-sandbox-default-off
Default Windows bash sandbox to off / Windows Bash 沙箱默认关闭
2026-07-06 22:03:44 +08:00
SivanCola d2f0030732 Apply user config upgrades on CLI startup
Run ApplyUserConfigUpgradesOnStartup from migrateLegacyConfigForCLI so
Windows CLI-only users also get the one-time bash = "enforce" to off
migration instead of waiting for a desktop launch. The upgrade is
config_version-gated, so desktop and CLI never double-apply it, and
metadata commands (version/help) still leave the config untouched.
Also drop the unused bool return of the migration helper.
2026-07-06 21:58:39 +08:00
SivanCola 48812e90f1 Simplify bash sandbox delta render and guard GOOS test overrides
Drop the dead BashMode comparison in the project delta condition (an
empty raw value always resolves to the platform default on both sides)
and replace per-test runtimeGOOS save/restore boilerplate with a
setRuntimeGOOS helper whose t.Setenv call panics under t.Parallel,
keeping the package-global override race-free.
2026-07-06 21:58:30 +08:00
SivanCola 7ae4219dcc Merge pull request #6105 from SivanCola/fix/mcp-toolset-session-pinning
fix(plugin): pin cache-hit MCP placeholder tools for the whole session
2026-07-06 21:33:06 +08:00
SivanCola 4a4426634b Fix Windows bash sandbox upgrade lint 2026-07-06 21:31:42 +08:00
SivanCola 255189876b test(plugin): poll for the refreshed cache instead of the first loadable one
macOS CI caught a race in the new divergent-handshake test: the stale
cache the test writes is itself loadable, so waitForCachedSchema could
return it before the background saveLazyCachedSchema landed. Poll until
the cache actually contains the live tool set (bounded by the same 5s).
2026-07-06 21:23:30 +08:00
merge-order-check 10eb314bdb Default Windows bash sandbox to off 2026-07-06 21:20:24 +08:00
SivanCola 808abb9f3a fix(plugin): pin cache-hit MCP placeholder tools for the whole session
The provider request's tools array is part of the cached prompt prefix,
and cache_shape.go already tracked mid-session tool changes as a "tools"
prefix invalidation (a full-conversation cache miss at 10x pricing).
The lazy-placeholder mechanism registered byte-stable cached schemas at
boot — and then trySwap replaced them with the live tools as soon as
the background handshake finished. Whenever the live handshake differed
from the cache (description tweaks, schema upgrades, newly added
tools), the swap rewrote the tools array mid-session and cold-started
the conversation's provider cache.

Pin cache-hit placeholders instead: trySwap no longer touches the
registry for them. The lazyTools keep presenting the cached bytes the
model has seen since boot and forward Execute to the real tools through
the shared spawn state (which already worked — the swap was never
functionally necessary). Live drift still lands in the schema cache via
saveLazyCachedSchema, so the NEXT session presents the updated surface:
freshness deferred one session for byte-stable tool bytes within this
one — the same trade the environment-probe snapshot makes for the
system prompt.

The cache-miss connect stub still swaps (there was nothing real to
present); that is a one-time cost per server, and the background kick
usually completes before the first user message anyway. A snapshot with
zero cached tools now also takes the stub path — previously its live
tools joined the registry mid-session with no placeholders at all.

Covers all trySwap callers including the IsServerAlreadyConnected
shared-host recovery paths. Verified by reverting the guard: the new
divergent-handshake test fails with the old swap behavior.
2026-07-06 21:15:40 +08:00
SivanCola 4f54c51eef Merge pull request #6102 from SivanCola/feat/recovery-branch-gc
feat(desktop): garbage-collect redundant recovery branches to trash
2026-07-06 21:03:20 +08:00
SivanCola 89f52f1b6e fix(desktop): gate the first recovery-GC sweep on tab restore
Review catch: startup launches restoreOrBuildTabs on a goroutine and
started the GC immediately after, so the first sweep could run against
the pre-restore empty tab map. Two consequences: sessionOpenInAnyTab
would judge every saved tab's session as closed (a recovery tab about
to be restored could be swept to trash), and DeleteSession's tab-list
persistence would overwrite desktop-tabs.json with the empty snapshot,
wiping the user's saved tab layout.

Add a tabsRestored gate: armed in startup before the restore goroutine
launches, closed via defer when restoreOrBuildTabs finishes (both the
saved-tabs and first-launch paths, and the panic-recover path). The
GC's first sweep waits on the gate or ctx cancellation. markTabsRestored
is idempotent; tabsRestoredSignal reports already-restored when the gate
was never armed, so App instances built directly in tests keep working.

Regression test forces the exact race: gate closed → sweep provably
blocked and the branch untouched; restore lands the tab holding the
branch and opens the gate → the sweep runs and skips the now-open
branch.
2026-07-06 20:51:45 +08:00
SivanCola f50babf8a5 Merge pull request #6088 from esengine/codex/session-trash-friendly-errors
fix(desktop): surface actionable errors when session cleanup hits a blocked file
2026-07-06 20:36:15 +08:00
SivanCola 2f1302b449 feat(desktop): garbage-collect redundant recovery branches to trash
Conflict recovery forks a copy of the transcript on every save conflict
(#5993). The triggers are fixed, but every fork that ever happened still
sits in the session list until the user trashes it by hand — the
"会话列表被污染" half of that report was never addressed.

Add a conservative GC. agent.ReclaimableRecoveryBranches reclaims a
branch only when EVERY condition holds, because a recovery branch exists
precisely to prevent data loss:
  - meta says Recovered with a fork digest;
  - the transcript still matches that digest (never continued on — one
    follow-up turn disqualifies it permanently);
  - the parent (meta.ParentID) still exists and COVERS the branch content
    (equal digest, or branch is a compatible prefix — the same checks
    SaveRecoveryBranch uses to declare recovery unnecessary);
  - no runtime holds the branch's session lease;
  - it has been idle past a 24h grace period.

The desktop sweeps every session directory it lists from, once at startup
and every 6h, re-checking liveness (lease + open-in-tab) immediately
before disposal and routing removal through DeleteSession — so a swept
branch is moved to the recoverable session trash, never hard-deleted.

Verified the coverage guard by reverting it: a diverged fork (content
present nowhere else) then becomes reclaimable and the test fails.
2026-07-06 20:25:09 +08:00
SivanCola c0fecd8779 fix(desktop): satisfy session error lint 2026-07-06 20:23:40 +08:00
SivanCola 0645b0e8e3 Merge pull request #6096 from SivanCola/fix/cost-display-scope-cleanups
fix(ui): make every cost/cache readout match its labeled scope
2026-07-06 20:12:35 +08:00
SivanCola 72f0210bb4 fix(desktop): align ContextPanel session cache average with all-sources scope
Review catch on this PR: the panel's session cache average still
preferred usage.sessionCacheHitTokens — the wire session counters the
Go agent scopes to the executor only — over the all-sources telemetry,
while the same panel's session cost and per-source rows (and StatusBar
after this PR) already report all sources. Because the reducer keeps
s.usage across context refreshes, the executor-only number would stick
indefinitely.

Extract contextSessionCache: panel info first, ContextInfo second, wire
counters only as the pre-refresh bridge for a fresh session — and pick
hit/miss as a pair from a single source so the computed rate can never
mix scopes (the old per-field ?? chains could).
2026-07-06 19:37:42 +08:00
SivanCola 69984b1bca Merge pull request #6100 from esengine/codex/pr6095-extra-regression-tests
test(agent): cover snapshot repair edge cases
2026-07-06 19:33:11 +08:00
SivanCola 072cf44246 Merge pull request #6097 from SivanCola/fix/winsandbox-hidden-console-lock-holder
fix(sandbox): hide Windows sandbox consoles and fail lock waits fast
2026-07-06 19:30:47 +08:00
SivanCola 9a38adde4d Merge pull request #6094 from SivanCola/test/rebind-byte-stability
test: guard prompt-prefix byte stability at boot and desktop rebind level
2026-07-06 19:26:50 +08:00
SivanCola d6f196ac94 test(agent): cover snapshot repair edge cases 2026-07-06 19:26:36 +08:00
SivanCola 59df72bde9 fix(ui): make every cost/cache readout match its labeled scope
Four labeled-scope mismatches in cost/cache displays, bundled as the
tail of the cost-display cleanup line (#6075 fixed the big one):

- Desktop status bar 平均命中 silently switched scope: the live value
  used the wire session counters (executor agent only) while the
  fallback used tab telemetry (all request sources). Prefer the
  all-sources telemetry — matching the 会话费用 tooltip's "includes
  main model, subagents and auxiliary calls" — with the executor-only
  live counters only bridging a fresh session's first turn.
- CLI status line painted "turn hit 0.00%" for providers that report
  no prompt-cache fields, by falling back to PromptTokens as the
  denominator. The tag now hides when cache fields are absent; a
  genuine full miss (fields present, hit zero) still renders.
- serve web UI "总费用" accumulated since page load across session
  rotations and fell back to a single request's cost. The counters now
  reset on /new and /resume, the single-request fallback is gone, and
  the card is relabeled 会话费用/Session Cost.
- Desktop ContextPanel's session-cost fallback chain could render one
  request's usage.cost under the 会话费用 label; single-request
  branches removed (usage now contributes currency only).
2026-07-06 19:20:00 +08:00
SivanCola 72a0bf225e fix(sandbox): hide Windows sandbox consoles and fail lock waits fast
- Launch sandboxed user commands and internal icacls/taskkill with
  CREATE_NO_WINDOW + STARTF_USESHOWWINDOW/SW_HIDE so no black console
  flashes over the desktop (npm run dev / icacls windows in the report).
- Record each root lock's holder (pid + command preview) in a sidecar
  file; the queue notice and timeout error now name the blocking command
  so users know what to stop instead of only being told to raise
  WINDOWS_SANDBOX_LOCK_MS.
- Drop the interactive lock wait default to 1 minute so a queued
  foreground command fails fast into the existing escape-approval path;
  background bash jobs keep the patient 10-minute wait via Spec.LockWait
  (WINDOWS_SANDBOX_LOCK_MS still overrides both).
2026-07-06 19:19:55 +08:00
SivanCola 72382df0e9 Merge pull request #6095 from SivanCola/fix/snapshot-interrupted-tool-tail
fix(agent): stop load-time repairs from masquerading as snapshot conflicts
2026-07-06 19:17:28 +08:00
SivanCola 1192fad2f5 test(desktop): compare rebind request against a no-rebind baseline
Review feedback on the rebind guard: comparing after[:len(before)]
against the FIRST turn's request only proved the system prompt and
first user message stayed stable — the resumed prefix's assistant/tool
messages were never compared.

Strengthen to the full comparison: from one transcript saved after turn
one, run the same follow-up turn twice — once on the original
controller (no rebind, the cache-warm baseline) and once after the
desktop rebind path — and require the two requests to be byte-identical
END TO END. The rebind now loads an independent copy of the saved
transcript, since the baseline turn autosaves onto the original path.
The drifted-prompt canary gets the same baseline structure and
additionally pins the divergence to the leading system message.
2026-07-06 19:13:04 +08:00
SivanCola 1437d5a24a fix(control): self-heal fork-orphaned in-flight turn markers on open
The marker transplant stops new recovery forks from leaving in-flight
turn markers behind, but every fork made by an older runtime already
left one on its forked-from branch. Opening such a branch fired
recoverInterruptedTurn against a turn that completed on the recovery
child, stripping the transcript back to the turn boundary and
force-saving the truncation.

recoverInterruptedTurn now recognizes the leftover: a recovery branch
whose parent is this session and whose creation postdates the marker
means the marked turn moved there rather than dying with a runtime.
Clear the stale marker and keep the messages; a marker with no such
child still strips the partial tail as before.
2026-07-06 19:04:33 +08:00
SivanCola 6db192b8e7 test: guard prompt-prefix byte stability at boot and desktop rebind level
Final defense line for the provider prefix cache, closing the CI gaps
the desktop-cost triage identified: nothing verified that (a) prompt
composition is deterministic and (b) the desktop rebind path preserves
request bytes.

- TestBuildComposesByteStableSystemPrompt: two Builds over the same
  workspace/config must compose byte-identical system prompts; a
  failure prints a window around the first diverging byte so the
  drifting section is named. Probes are covered cross-process by the
  persisted-snapshot tests; this pins the rest of the composition
  (memory, skills index, output style, policies).
- TestRebindKeepsRequestPrefixBytes: after the desktop rebind shape
  (LoadSession + sessionWithFreshSystemPrompt + Resume on a fresh
  controller), the next request's leading messages must be
  byte-identical to the request sent before the rebind.
- TestRebindWithDriftedPromptBreaksRequestPrefix pins the failure mode
  the guard exists for (a drifted prompt breaks the prefix), so a
  future swap-policy change updates the guards consciously.
2026-07-06 18:59:27 +08:00
SivanCola 7932bc1e46 fix(control): move the in-flight turn marker onto the recovery branch
A snapshot-conflict recovery fork switches the controller onto the new
branch mid-turn, but the in-flight turn marker written at turn start
stayed on the original path — turn end then cleared the (absent) marker
on the recovery path, leaving the original's marker behind forever. The
next open of the original branch fired recoverInterruptedTurn against a
turn that in fact kept running on the recovery branch, stripping every
message after the turn boundary and force-saving the truncation.

Transplant the marker when the fork switches paths: mark the recovery
branch (so a crash before turn end still strips correctly there) and
clear the forked-from branch.
2026-07-06 18:56:39 +08:00
SivanCola a1fecd3aa0 fix(agent): keep dirty-resume saves off the append shortcut, raw-check recovery need
Two more sites judged the normalized load as if it were the disk truth:

- checkSnapshotWrite let a session resumed from a repaired transcript take
  the append-only shortcut with appendFrom measured against the normalized
  view. The event log replays the raw transcript, so the append event's
  index broke the replay chain and the whole appended turn silently
  vanished from disk on the next load (until a later save healed the log).
  A pending repair now falls through to the full rewrite, which persists
  the repair and the new turn together.

- SaveRecoveryBranch decided "recovery not needed" only against the
  normalized view, so a mid-history repair (e.g. an empty tool-call name
  backfilled on load) made a fully-covered snapshot look uncovered and
  forked a pointless recovery branch. Coverage now falls back to the raw
  transcript, mirroring checkSnapshotWrite.
2026-07-06 18:53:45 +08:00
SivanCola a84f2aeb03 Merge pull request #6093 from SivanCola/fix/env-probe-snapshot-byte-stability
fix(environment): persist probe snapshots so the prompt prefix stays byte-stable
2026-07-06 18:53:20 +08:00
SivanCola b96f152a07 Merge pull request #6092 from esengine/codex/desktop-localized-error-notices
Localize desktop error notices
2026-07-06 18:50:35 +08:00
SivanCola 09ce7df436 fix(agent): judge snapshot appends against the raw on-disk transcript
A mid-turn autosave can legitimately cut an assistant tool call from its
still-running result, leaving a dangling call at the tail of the saved
transcript. checkSnapshotWrite compared the next snapshot against the
LoadSession view of disk, where normalization has already fabricated a
placeholder answer for that call. The live session's real tool result
collides with the placeholder, so a pure append misread as diverged and
forked a "snapshot conflict" recovery branch 30 seconds after the first
mid-turn save of any tool-heavy turn.

Keep the pre-repair transcript on loaded sessions and fall back to it in
checkSnapshotWrite when the normalized prefix checks fail: append shape,
the owned-rewrite digest, and conflict-kind classification are now judged
against the bytes actually on disk. Appending the real results also lands
the transcript well-formed, so no repair is left pending in that path.
2026-07-06 18:42:42 +08:00
SivanCola 3eda5346a9 fix(environment): persist probe snapshots so the prompt prefix stays byte-stable
The environment section sits inside the provider-cached system-prompt
prefix, but probe results were point-in-time observations with only a
5-minute in-memory cache: a 2s timeout flips a slow tool between
"found" and "timeout", a GUI-launched desktop resolves a different PATH
than a login shell, a cold docker daemon reports an exit error. Every
flip rewrote the prefix; the desktop's fresh-system-prompt swap then
propagated the drifted prompt onto every resumed session — a
whole-conversation cache miss at 10x pricing plus a persisted rewrite.
This was the main mechanism behind "desktop costs more than CLI"
(#2945, #5850 comments).

- Persist one probe snapshot per fingerprint under the shared cache
  root (ProbeOptions.SnapshotDir, opt-in; boot passes config.CacheDir).
  A snapshot younger than 24h serves without re-probing, so rebuilds,
  app relaunches, and the CLI and desktop on the same machine render
  identical environment bytes. 24h aligns with cacheColdAfter: past a
  day idle the provider cache is dead anyway, so a refresh then is free.
- Flap merge on refresh: transient failures (timeout, nonzero exit)
  keep the previous successful observation; definitive results (found,
  not found, not trusted) always win, so uninstalled tools still drop
  out.
- Desktop resume logs when it swaps a system prompt whose bytes differ
  from the persisted one — with deterministic composition this should
  only fire on genuine config changes, so a field log without one
  points at a new nondeterminism source.

The snapshot dir is host state and stays out of the probe fingerprint.
2026-07-06 18:38:13 +08:00
SivanCola d580bb38d5 Localize provider extra body syntax errors 2026-07-06 18:35:05 +08:00
SivanCola 068f35dc77 Localize desktop error notices 2026-07-06 18:21:38 +08:00
SivanCola dce3b3a70e Merge pull request #6076 from SivanCola/chore/internalize-windows-sandbox
Internalize Windows sandbox backend / 内置 Windows 沙箱后端
2026-07-06 17:37:25 +08:00
SivanCola e38623e255 Merge pull request #6077 from SivanCola/fix/new-session-running-guard
fix(control): refuse NewSession while a turn is running
2026-07-06 17:28:04 +08:00
SivanCola 28d0ab8677 fix(sandbox): clean up Windows helper availability checks 2026-07-06 17:27:37 +08:00
SivanCola a0337caf9b fix(desktop): surface actionable errors when session cleanup hits a blocked file
Raw OS errors from session trash/restore/purge — Windows sharing
violations while antivirus or sync tools hold a transcript, access
denied, disk full — previously reached the UI toast verbatim. Map them
at the App entry points (TrashTopic, DeleteTopic, DeleteSession,
RestoreSession, PurgeTrashedSession) to sanitized, path-free messages
with retry guidance, following the errSessionBusyElsewhere precedent.
The original error is logged so diagnostics keep the path and errno;
unrecognized errors pass through unchanged.
2026-07-06 17:27:26 +08:00
SivanCola 203ab34a81 fix(control): gate summarizeAt through the rotation gate
SummarizeFrom/SummarizeUpTo still used the bare Running() check before
rewriting the live session — and their window is the widest of the
class: the summarizer performs a provider round-trip before
Session.Replace, leaving seconds for a turn to start and then have the
log replaced under it. Reachable from desktop summarize actions, the
TUI rewind menu, and serve /summarize.

Route summarizeAt through beginRotation()/endRotation() from the
boundary read through the post-rewrite snapshot, keeping the original
"cannot summarize while a turn is running" message on the running path.

Also strengthen TestSessionMutationsRefuseWhileRotating: every gated
method now asserts the errRotationInProgress sentinel instead of any
non-nil error, so each method's wiring is independently
regression-proof (a bare-Running() revert previously slipped past the
weaker checks by failing later for an unrelated reason). Verified the
summarize assertions fail with the gate removed.
2026-07-06 17:17:31 +08:00
SivanCola bb0dd15e1b Merge pull request #6085 from esengine/codex/session-trash-missing-path
fix(desktop): make session trash tolerate missing artifacts
2026-07-06 17:13:38 +08:00
SivanCola 387cc22517 fix(sandbox): route the Windows helper in the desktop app and fail closed without it
The Windows bash sandbox relaunches os.Executable() as a hidden helper
process, but the desktop binary never routed that subcommand: every
sandboxed command started a second GUI instance that forwarded to the
running app via the single-instance lock and exited 0 with no output, so
bash silently returned empty on Windows (#6046, #6051, #6067, #6070,
#6072). The 1.17.3 escape prompt never fired because it keys on the
helper's exit-code-126 failure marker, which a dispatch-less binary
never prints.

- desktop/main.go dispatches the helper subcommand before any
  Wails/single-instance setup, with tests pinning the route.
- Entry points now declare the route via
  sandbox.RegisterHelperDispatch(); on Windows, Available() stays false
  without it and Command/CommandArgs refuse to wrap, so a future
  dispatch-less embedder fails closed with the existing clear error and
  escape-approval path instead of swallowing output.
- Queueing behind another sandboxed command's per-root lock now emits a
  one-line notice (sliced mutex waits) instead of blocking silently for
  up to 10 minutes; recorded as a local modification in the winsandbox
  NOTICE.
- reasonix doctor reports the resolved shell (Git Bash vs PowerShell is
  the first thing to check for low-integrity launch failures) and warns
  when a project reasonix.toml pins [sandbox], which overrides
  Settings/user-config edits (#5961 confusion).
- GUIDE/GUIDE.zh-CN gain a Windows sandbox troubleshooting note.
2026-07-06 17:09:14 +08:00
SivanCola 1e408f5e33 fix(desktop): drop partial trash copy when source vanishes mid-copy
If the source disappears while the copy fallback is running, copyAndRemove
now removes the partially written destination before treating the artifact
as already moved, so the trash never keeps a truncated transcript that a
later restore would resurrect. Real copy failures still propagate and leave
the source untouched.
2026-07-06 17:01:30 +08:00
SivanCola 252ff75f17 fix(control): route all session mutations through the rotation gate
NewSession/ClearSession closed their turn↔swap TOCTOU window with the
rotation gate, but the same bare-Running()-then-mutate-later shape lived
in every other interactive session-mutating entry point:

- forkNamed: Running() check, then Snapshot(), then SetSession
- Branch:    read running, then Snapshot(), then SetSession
- SwitchBranch: read running, then Branches()/LoadSession, then SetSession
- Rewind:    Running() check, then Session().Replace + SnapshotRewrite
- Compact:   Running() check, then CompactNow rewrites the live session

All are dispatched from submit()'s synchronous slash-command path (or
Fork wrappers), so a user can trigger them while a turn streams — the
guard existed for exactly that reason, but a turn starting after the
check and before the mutation slipped through, replacing/rewriting the
session under the run loop.

Route all five through beginRotation()/endRotation(), same as
NewSession/ClearSession: the gate is held from before the snapshot/load
through the swap, and runGuarded/RunTurn already refuse to start while
c.rotating. Each keeps its original "while a turn is running" message on
the running path and reports errRotationInProgress for the newly-closed
window. None of these methods is called from inside a turn body, so the
gate is never re-entered (no deadlock).

TestSessionMutationsRefuseWhileRotating asserts all five (plus RunTurn)
refuse while a rotation holds the gate and the live session is untouched,
and that the gate cannot be claimed while a turn runs. Verified it fails
if the rotating check is removed.
2026-07-06 17:00:13 +08:00
SivanCola a6f96dc2e4 fix(control): close the NewSession/ClearSession rotation TOCTOU window
The running guard checked c.running once at entry, released c.mu, and
only swapped the executor session after Snapshot(). A turn could start
during that snapshot window (running was false at the check) via
runGuarded/RunTurn and then have its live session replaced by
SetSession — the exact race the guard meant to prevent, just moved later.

Replace the point-in-time check with a rotation gate: beginRotation
atomically refuses when a turn is running or another rotation holds the
gate, and stays held (c.rotating) from before Snapshot() through the
swap. runGuarded and RunTurn now also refuse to start while c.rotating,
so turn-start and session-rotation are mutually exclusive in both
directions and the run loop's session reference cannot change under it.

Regression test forces the interleaving deterministically by parking
NewSession inside Snapshot()'s recovery callback, then asserting a
concurrent RunTurn is refused, running stays false, and the session is
not mutated until the rotation completes. Verified it fails without the
c.rotating checks.
2026-07-06 16:45:54 +08:00
SivanCola c79a15de28 fix(desktop): make session trash tolerate missing artifacts 2026-07-06 16:22:29 +08:00
SivanCola 395aa3d799 fix(control): refuse NewSession while a turn is running
ClearSession and Compact both guard against rotating the session out
from under a running turn, but NewSession did not. Both the Submit
"/new" path (go c.NewSession()) and the bot gateway invoke it
asynchronously, so a mid-turn /new could swap the executor session
while the run loop reads it directly and race the turn-end snapshot
onto the fresh path.

Mirror ClearSession's running guard in NewSession. The bot gateway
cancels the in-flight turn before rotating, and that cancel is
asynchronous — give it a bounded window to unwind (same 5s/10ms shape
as the desktop's waitControllerStopped) and report the failure to the
chat instead of claiming a new session started.
2026-07-06 16:20:29 +08:00
SivanCola 52cc819466 Merge pull request #6082 from SivanCola/fix/recovery-chain-depth-cap
fix(agent): cap recovery chain depth and force-save at the cap
2026-07-06 16:18:50 +08:00
SivanCola 67bd9f19f1 Merge pull request #6080 from SivanCola/fix/snapshot-conflict-trigger-telemetry
feat(control): log snapshot-conflict triggers with the revision ledger
2026-07-06 16:10:49 +08:00
SivanCola bd33d5138c Merge pull request #6074 from SivanCola/fix/session-recovery-handoff-serialization
Serialize session recovery snapshots / 串行化会话恢复快照
2026-07-06 16:05:07 +08:00
SivanCola 790d06c4a3 Merge pull request #6079 from SivanCola/ci/cache-impact-desktop-watchlist
ci(cache-impact): watch desktop session prompt swap and environment probes
2026-07-06 16:02:25 +08:00
SivanCola 5ceab67371 fix(control): truncate the cancel flush under the snapshot handoff lock
replaceSessionAfterCancel took snapshotMu only around the SaveRewrite/
recovery flush, but the in-memory Replace ran before it. A mid-turn
autosave already holding the lock could then capture the truncated
transcript mid-save, read the longer partial autosave on disk as a
stale-prefix conflict, and adopt it back into the executor — silently
undoing the cancel cleanup before the flush could persist it, and
pointing the later SaveRewrite at the resurrected session.

Hold snapshotMu from the top of the cleanup so the truncation, todo
rebuild, path read, and flush are one atomic handoff.

The CancelFlush regression case now performs a real truncation (drop the
assistant reply, the shape stripTurnMessagesAfter produces) instead of a
same-content no-op, asserts the in-memory transcript is untouched while
the blocked handoff is still in flight, and verifies the truncation
lands on the recovery transcript afterwards. The blocked-phase assertion
fails against the previous code.
2026-07-06 15:57:44 +08:00
SivanCola 909bea3618 Merge pull request #6073 from SivanCola/fix/readiness-permission-loop
Stop readiness permission loops / 阻止 readiness 权限循环
2026-07-06 15:55:07 +08:00
SivanCola 82a9d866d6 fix(agent): cap recovery chain depth and force-save at the cap
A save-conflict loop used to fork recovery branches from recovery
branches without bound — #5993 reached 8 nested levels, each fork
polluting the session list. The conflict triggers behind that loop are
fixed, but nothing prevents a future regression from spawning chains
again: BranchMeta.Recovered was display-only and SaveRecoveryBranch
never looked at the parent's recovery state.

- Stamp BranchMeta.RecoveryDepth on every recovery fork (parent depth
  + 1; legacy recovery metas count as depth 1).
- SaveRecoveryBranch refuses to fork a parent already at
  SessionRecoveryMaxDepth (3) with ErrSessionRecoveryDepthExceeded.
- At the cap the controller force-saves the transcript onto the
  current branch instead: this runtime is the only writer of recovery
  branches it created, so the force write keeps the data, re-anchors
  the persistence baseline (no immediate re-conflict), and stops the
  file proliferation. A warn log plus a distinct notice make the
  condition visible.
2026-07-06 15:47:28 +08:00
SivanCola e4afe93738 fix(agent): harden readiness loop-guard pass and blocked-storm detection
- Replace the readiness loop-guard text scan with host state armed by the
  storm breaker itself: a guard on any result of a multi-call batch now
  unlocks the final blocker report, and tool output that merely quotes
  "[loop guard]" no longer does.
- Keep the pass across the bookkeeping the guard recommends (ask,
  todo_write, complete_step); revoke it once a successful write or command
  receipt proves receipts are obtainable again.
- Add a blocked-turn streak detector so rotating tools, reordering batches,
  or blockers whose text varies per attempt still trip the guard after
  three consecutive fully-blocked turns.
2026-07-06 15:44:50 +08:00
SivanCola 89b7555861 fix(control): serialize session swaps with the snapshot handoff
snapshotMu closed the snapshot-vs-snapshot recovery race, but snapshot
still reads sessionPath and the executor session as two separate steps,
and every session/path swap mutated that state without the lock. A swap
landing between the two reads pairs the old path with the new session:
best case it manufactures the same spurious conflict/recovery cascade,
worst case an append-shaped mismatch writes one transcript's messages
into another transcript's file (the loaded session has no persisted
baseline for the old path, so the revision CAS is disarmed).

Take snapshotMu across the swap sections of NewSession, ClearSession,
forkNamed, Branch, SwitchBranch, Resume, and SetSessionPath. ClearSession
holds it from artifact removal onward so a racing save cannot resurrect
the just-removed transcript. Resume releases it before
recoverInterruptedTurn/maybeColdResumePrune, which snapshot on their own
(the lock is not reentrant).

replaceSessionAfterCancel was a second save/recovery handoff outside the
lock: its post-cancel flush calls SaveRewrite and recoverSnapshotConflict
directly. Serialize it the same way and read the path under the lock so
an in-flight recovery retarget cannot leave it stale.

Regression test blocks the first recovery callback and races
SetSessionPath, Resume, and the cancel flush against the in-flight
handoff: none may complete until the handoff commits, and only one
recovery transcript may exist afterwards.
2026-07-06 15:38:51 +08:00
SivanCola 3b723f8f37 feat(control): log snapshot-conflict triggers with the revision ledger
Field reports of frequent "session changed on disk" notices (#6069)
are undiagnosable today: the stale-prefix adoption and the
recovery-not-needed adoption emit the same notice text, nothing logs
which trigger fired, and the revision ledger that drove the conflict
decision is discarded.

- Log every recoverSnapshotConflict outcome (stale-prefix adopt,
  recovery-not-needed adopt, failed adoption, recovery fork) with the
  conflict kind, save mode, message counts, and the
  BaseRevision/DiskRevision pair from SessionSnapshotConflictError.
- Differentiate the recovery-not-needed notice text ("local changes
  already covered") so user screenshots identify the trigger.
- The recovery-not-needed path whose disk adoption fails used to drop
  the snapshot silently; it now leaves a warning so "my last turns
  vanished" reports can be tied to it.
2026-07-06 15:37:45 +08:00
SivanCola 4ce57642ae ci(cache-impact): watch desktop session prompt swap and environment probes
The cache-impact gate watched only internal/ prompt surfaces, but two
cache-sensitive surfaces lived outside it:

- desktop/session_prompt.go rewrites the persisted leading system
  message with the freshly composed prompt on every tab resume/rebind;
  any byte difference there invalidates the whole conversation's prefix
  cache and persists the rewrite.
- internal/environment/* composes the environment section embedded in
  the cached system prefix; probe output changes shift the prefix bytes
  for every session.

Add both to the cache-sensitive list and to the system-prompt-sensitive
list so changes require Cache-impact/Cache-guard lines and an explicit
System-prompt-review note.
2026-07-06 15:32:21 +08:00
SivanCola 95f592a53d Merge pull request #6075 from SivanCola/fix/desktop-session-cost-scope
fix(desktop): key tab usage telemetry to its session so 会话费用 stops accumulating across sessions
2026-07-06 15:17:35 +08:00
SivanCola 5fe20ebda8 chore: internalize Windows sandbox backend 2026-07-06 15:11:47 +08:00
SivanCola 4da3f3e488 fix(desktop): key tab usage telemetry to its session
The per-tab usage telemetry that feeds the status bar 会话费用 had no
session identity, so its totals leaked across session rotations and were
then persisted into the wrong session's .telemetry.json sidecar:

- App.NewSession and the clear-while-running rebuild never reset it
  (only ClearSession did), so a "new session" kept the old running total.
- Rebinding a tab to a session without a telemetry sidecar (fresh
  session, CLI-created session) kept the previous session's totals and
  saved them into the new session's sidecar on the next event, so the
  contamination compounded into an all-sessions running total (#5850).
- A typed /new routes through Controller.Submit and rotates the session
  without the App layer ever noticing, so no App-side reset can cover it.
- A save-conflict recovery fork carried cost in memory but never wrote
  the fork's sidecar, undercounting to zero after a restart.

Fix: key the in-memory telemetry with the tab's sessionRuntimeKey and
re-sync at every telemetry writer (usage / turn start / turn done /
read-file recorders) and reader (ContextUsageForTab, ContextPanel). On a
key mismatch the new session's persisted sidecar is swapped in, or zero
when none exists. NewSession and the clear-while-running path reset the
telemetry to the rotated session; the build/rebind restore is now
unconditional and keys the telemetry; recovery forks re-key the carried
totals and persist the fork sidecar at retarget time; detach/attach tab
clones carry the key.
2026-07-06 15:08:55 +08:00
SivanCola c69b4d9540 fix(control): serialize session recovery snapshots 2026-07-06 15:05:54 +08:00
SivanCola 4d1998b0ca fix(agent): stop readiness permission loops 2026-07-06 14:59:40 +08:00
SivanCola 3d2ed10742 Merge pull request #6063 from SivanCola/fix/session-exact-append-stale-baseline
Fix session append recovery conflicts / 修复会话追加误入冲突副本
2026-07-06 14:22:12 +08:00
SivanCola f4591d7c1c fix(agent): let a same-content foreign stamp keep rewrite ownership
A mirror runtime healing the revision ledger over identical content
(same-content save recording a missed revision) advanced the revision
under its own writer id. The next owned rewrite on this side — a
cancelled turn's truncation or an auto-compaction — then read as a
stale-runtime conflict and forked a recovery branch, even though the
on-disk transcript was byte-for-byte what this session last persisted.

ownsPersistedState now consults the ledger's recorded content digest:
when the baseline digest, the on-disk transcript digest, and the
ledger digest all describe the same bytes, a foreign revision stamp is
bookkeeping over content this session owns, and rewriting it destroys
nothing of the stamper's. At worst the conflict relocates to the
stamper's next divergent save, where its own in-memory history forks a
recovery branch as usual — no content-loss path opens.

A stamp whose digest disagrees with the on-disk transcript still
revokes ownership: that is the aftermath of a save whose bytes landed
but whose record failed, the bytes cannot be attributed to a writer
(event writer ids are per-process, not per-session), and only the
conservative conflict path preserves them.
2026-07-06 14:05:08 +08:00
SivanCola 895972d792 fix(agent): anchor stale-baseline session saves to the persisted baseline
Replace the exact-append CAS exemption with a reachability guard:
locate the persisted baseline digest among the pending snapshot's
incremental prefix digests (re-anchored on the disk system message when
a resume swapped the prompt) and require the on-disk transcript to
still reach that depth.

This extends the stale-revision exemption to compatible-system appends
(resume swapped the leading system prompt, then a routine turn append
forked a spurious recovery branch), while closing the gap the
exact-append exemption opened: an append over a transcript another
runtime deliberately rewound below the baseline now conflicts again
instead of silently resurrecting the removed suffix.

ownsPersistedState no longer lets an absent revision ledger revoke
rewrite ownership: recorded revisions start at 1, so a disk revision of
0 means the meta sidecar was deleted or rebuilt by a listing-only
writer after this session's save — the digest+version match already
proves the transcript is ours. Cancel-turn truncations and compaction
rewrites therefore survive a swept sidecar. A foreign recorded revision
still revokes ownership, preserving the guard against stale controllers
force-rewinding a ledger another runtime has stamped.
2026-07-06 13:07:23 +08:00
SivanCola 190a52283d Merge pull request #6062 from esengine/codex/desktop-release-latest
fix(release): keep desktop as repository latest
2026-07-06 12:50:32 +08:00
SivanCola 7f9491966c fix(agent): allow safe session append from stale revision baseline 2026-07-06 12:25:04 +08:00
SivanCola 97c746625a fix(release): keep desktop as latest release 2026-07-06 12:23:43 +08:00
SivanCola 0f4e3f3bb8 Merge pull request #6056 from SivanCola/fix/desktop-meta-git-branch-async
Avoid blocking metadata hydration on git branch probes / 避免 git 分支探测阻塞元信息刷新
2026-07-06 11:52:46 +08:00
SivanCola b922cb2f64 Merge pull request #6057 from esengine/codex/hydrate-performance-breadcrumbs
test: Assert stale hydrate metadata is ignored
2026-07-06 11:46:25 +08:00
SivanCola 53138c7658 test(desktop): close probe release channel once to fix test race 2026-07-06 11:45:44 +08:00
SivanCola b1c357812d fix(desktop): clear branch refresh flag even if the probe unwinds 2026-07-06 11:36:03 +08:00
SivanCola 345212f7fb test: assert stale hydrate metadata is ignored 2026-07-06 11:28:48 +08:00
SivanCola d60c52e5c7 fix(desktop): refresh metadata git branch asynchronously 2026-07-06 11:25:59 +08:00
SivanCola d58238782c Merge pull request #6036 from SivanCola/fix/global-topic-index-repair
fix desktop Global topic index repair
2026-07-06 10:39:38 +08:00
SivanCola aabf833416 Merge pull request #6041 from SivanCola/fix/rebuild-recovery-path-continuity
Continue on recovery path when pre-rebuild snapshot conflicts / 重建前快照冲突时沿恢复分支继续写入
2026-07-06 10:33:24 +08:00
merge-order-check 2d1b53d7be skip tombstoned topics when reusing indexed blank topics 2026-07-06 10:07:04 +08:00
merge-order-check 4ed24c6bf4 fix(acp): resolve session ids to the recovery transcript after restart
sessionRecoveredHandler moved the live session's transcript and lease to
the recovery file, but persisted lookups still treated the id-keyed path
as canonical: after a process restart, session/load and session/resume
reopened the pre-recovery transcript (the user's recovered work silently
vanished from ACP's view), loadMeta read the stale sidecar, non-live
session/delete removed the original while the recovery file lingered as
an undeletable orphan, and session/list could surface the stale sidecar's
title and timestamps.

Leave an ActiveTranscript redirect on the id-keyed sidecar when a recovery
commits (always written against the id-keyed path, so resolution stays a
single hop even for recovery-of-recovery), and resolve ids through it:
resolveTranscriptPath validates the redirect stays inside the session dir,
exists, and claims the same session id before following, falling back to
the id-keyed path otherwise. session/load, session/resume, loadMeta, and
the non-live delete fallback all resolve through it; session/delete also
removes the id-keyed twin so neither file resurfaces as a ghost; and
session/list reduces the two sidecars claiming one id to the active
transcript's meta.

Regression tests cover restart-time load (resolved transcript holds the
recovered content), restart-time delete (both transcripts and sidecars
removed, list empty), and list showing exactly one entry backed by the
active transcript's metadata.
2026-07-06 09:51:06 +08:00
merge-order-check cf9afb9d9e fix(acp): move session transcript and lease with snapshot recovery
When a snapshot conflict retargeted the controller to a recovery branch,
the ACP session object kept sess.transcript and sess.lease on the original
file: session/prompt reported the stale transcriptPath, session/delete
destroyed the wrong file (orphaning the live recovery transcript), and the
controller kept writing the recovery file with no session lease guarding
it against other runtimes.

Wire control's OnSessionRecovered hook through SessionParams and the acp
factory into every controller the service builds. The handler acquires the
recovery lease before releasing the original (Rebind order), moves
sess.transcript, and saves ACP meta against the recovery file at commit
time; a lease failure aborts the recovery commit so the controller stays
on the original path. This covers both recovery triggers: the turn-end
autosave in persistAfterTurn and the pre-rebuild snapshot in
rebuildSession.

Regression tests assert transcript, lease, and controller agree on the
recovery path after both triggers, the original transcript's lease is
released, and follow-up saves do not derive a second recovery branch.
2026-07-06 09:43:41 +08:00
merge-order-check 7378f0ff1f fix(session): continue on recovery path when pre-rebuild snapshot conflicts
Controller rebuild paths (desktop model/effort/token-mode/settings switch,
serve switchModel, TUI /model and /effort, ACP rebuildSession) captured the
session path before snapshotting the old controller. A snapshot conflict can
retarget the controller to a recovery branch, so the stale pre-snapshot path
bound the just-recovered transcript back to the original file - every later
save re-conflicted and derived yet another recovery branch, looping recovery
notices on the affected session.

Capture the continue/resume/adopt path (and carried history) after Snapshot
instead, so a rebuild keeps writing the recovery branch the snapshot just
committed to. Desktop gains sessionPathAfterSnapshot to re-read the old
controller's path on all four rebuild paths; CLI/serve/ACP move the capture
below the snapshot call.

Regression tests cover each surface: the rebuilt controller must land on the
recovery path and a follow-up snapshot must not derive a second recovery
branch.
2026-07-06 09:43:41 +08:00
merge-order-check 1aebd4fcdf prune deleted topic titles on scan saves and hide tombstoned topics in tree 2026-07-06 09:39:47 +08:00
SivanCola 9f43d13f5e Merge pull request #6037 from SivanCola/fix/unique-trash-conflict
fix(desktop): preserve conflicting trash sessions
2026-07-06 09:14:22 +08:00
merge-order-check 22608aa2c7 repair only missing topic indexes and close delete tombstone race 2026-07-06 09:07:45 +08:00
merge-order-check b9beebd5bf fix(desktop): preserve conflicting trash sessions 2026-07-06 09:06:12 +08:00
SivanCola 082e188198 Merge pull request #5719 from GTC2080/GTC/fix-5717-tui-cjk-repaint
fix(cli): repaint wide-character input on Windows
2026-07-06 09:05:29 +08:00
SivanCola e155699904 Merge pull request #6039 from SivanCola/fix/npm-dist-tag-stable-promotion
fix(npm): promote stable publishes to the latest dist-tag
2026-07-06 08:50:03 +08:00
SivanCola 1f46e71c69 Merge pull request #6038 from SivanCola/fix/desktop-updater-ua-github-fallback
fix(desktop): identify updater traffic and add a GitHub manifest fallback
2026-07-06 08:49:48 +08:00
SivanCola 8d4cfad768 Merge pull request #6040 from SivanCola/ci/release-public-smoke
ci(release): smoke the public update paths after publishing
2026-07-06 08:49:32 +08:00
merge-order-check 7af507b2c3 fix(desktop): identify updater traffic and add a GitHub manifest fallback
Both manifest endpoints (dl.reasonix.io, crash.reasonix.io) share one
Cloudflare zone, so bot protection that 403s a user's egress IP takes out
the whole chain at once (#6005). Three changes:

- Send a descriptive User-Agent on manifest and artifact requests so the
  edge can allowlist updater traffic instead of scoring it as a generic
  Go client.
- Append the GitHub compatibility manifest as the stable channel's last
  resort. release.yml pins the desktop-manifest mirror to the repo-wide
  latest release (the compatibility asset), so the URL always serves the
  desktop manifest, from infrastructure outside that Cloudflare zone.
- Keep every endpoint's failure via errors.Join instead of surfacing only
  whichever endpoint died last, so reports like #6005 show the full chain.
2026-07-06 04:16:23 +08:00
merge-order-check 6bb5e61b7f ci(release): smoke the public update paths after publishing
The authenticated R2 smoke verifies the mirror landed but says nothing
about what end users' updaters actually reach — #5826/#5858 (manifest 404)
shipped broken for weeks because no release step exercised the public
edge, and #6005 (gateway 403) was only visible in user reports.

- release.yml: after attaching the desktop-manifest compatibility asset,
  fetch it back anonymously with a Go client UA via GitHub's public
  releases/latest URL — the exact path pre-v1.16 updaters poll. GitHub
  serves its own runners (no bot wall), so this asserts hard.
- release-desktop.yml: probe the release gateway the same way. 200 must
  serve the just-released version; 403 is the known bot-protection gap
  (#6005) and only warns until a WAF skip rule lands; 404/5xx/stale
  version fail the release.
2026-07-06 04:15:58 +08:00
merge-order-check adadf64fa1 fix(npm): promote stable publishes to the latest dist-tag
The "0.x stable owns latest" rule was a migration guard from when the 1.x
Go line was rc-only. Once 1.x went stable the guard inverted into a trap:
latest stayed pinned at 0.53.2, so pnpm update -g / npm update -g silently
downgraded every 1.x user back to the retired line, and the documented
manual promotion (npm dist-tag add) never happened (#5822).

Stable versions now always publish under latest; rc/beta stay on next and
canary stays on canary. A post-publish verify step polls the registry and
fails the release if the tag did not land — the guard #5822 lacked.
2026-07-06 04:13:43 +08:00
SivanCola d61dc916c8 Merge pull request #6034 from SivanCola/feature/session-lease-cli-acp-serve
Take session leases in CLI, ACP, and serve / CLI、ACP、serve 写绑定会话时接入会话租约
2026-07-06 03:56:24 +08:00
merge-order-check d1a3cc999c fix(ci): bound windows lease tests 2026-07-06 03:51:03 +08:00
merge-order-check 97bc4488b5 fix desktop global topic index repair 2026-07-06 03:45:04 +08:00
SivanCola 9c7c87e964 Merge pull request #6035 from SivanCola/fix/nested-tool-compass-icon
fix(desktop): refine nested tool count icon / 优化嵌套工具计数图标
2026-07-06 03:24:51 +08:00
merge-order-check 1e7a44102c fix(desktop): refine nested tool count icon 2026-07-06 03:19:02 +08:00
SivanCola 3211ad62a9 Merge pull request #6032 from SivanCola/fix/windows-sandbox-escape-prompt
Add Windows sandbox escape approvals
2026-07-06 03:14:22 +08:00
merge-order-check 4f3bfa9030 Add Windows sandbox escape approvals 2026-07-06 03:00:02 +08:00
merge-order-check 1aac234fc6 fix(serve): serialize session-path-changing handlers on one binding mutex
Review finding on this PR: /resume, /new, and /fork ran their
snapshot -> lease rebind -> controller bind sequences without any
cross-request serialization. net/http runs handlers concurrently and
one serve instance backs multiple browser tabs, so two interleaved
requests could leave the controller writing session B while the lease
keeper guards session C - B unprotected, C wrongly occupied - exactly
the split this PR exists to prevent. switchModel was serialized by its
own switchMu but nothing stopped the other handlers from rebinding the
same controller mid-switch.

Widen switchMu into bindMu and take it in every entry point that
changes the active session path (/resume, /new, /fork, switchModel).
The lock is acquired after body decoding and path validation, so a
slow client cannot hold the binding lock while uploading; lock order
stays bindMu -> s.mu with no reverse path.

Regressions: a deterministic interleave test parks the first resume
between its rebind and its Resume via a test hook (gated by an atomic
CAS, not sync.Once - Once.Do's internal mutex would accidentally
re-serialize the window) and asserts the second resume waits and both
land with controller and lease aligned; removing the guard makes it
fail. Plus two hammer tests (concurrent resume x resume and
resume x fork) asserting the same alignment invariant under -race.

Refs #6034 review.
2026-07-06 02:59:53 +08:00
merge-order-check caaead1cd9 feat(cli/acp/serve): take session leases when binding sessions for writing
Phase 2b of #6027: the desktop has held session leases since v1.16, but
CLI --resume/--continue, the chat TUI's session switches, serve, and ACP
never acquired one — leaving desktop+CLI on the same session as the last
real silent dual-writer entrance (CAS only detected it after the fact by
forking recovery copies).

- control.SessionLeaseKeeper: single-holder lease follower for the
  non-desktop frontends. Rebind acquires the new path before releasing
  the old one, no-ops on the already-held canonical path, releases on
  empty path, and leaves the previous lease untouched on failure.
  control.SessionInUseMessage renders the shared holder line (pid, host,
  since; never the writer id or the session path).
- CLI: run/chat resume targets fail fast before setup when held, with
  the holder line plus the two ways out (close the holder, or rerun
  with --copy). New --copy flag duplicates the resume target beside the
  original (event-log-aware Session.Save; parent/preview/turns/model
  meta; ' (copy)' title suffix) and continues writable on the copy.
  In-TUI /resume, /switch refuse held targets and keep the current
  session; /switch re-points the lease if the switch itself fails after
  the lease moved. /new, /clear, /branch, and rewind-fork follow the
  controller onto fresh paths. Leases release after the controller
  closes on exit.
- serve: POST /resume returns 409 with the holder line when held (state
  unchanged); /new, /fork, and model-switch path rotations follow their
  new paths. Read-only endpoints are unaffected.
- ACP: session/new, session/load, and session/resume guard their bind;
  held sessions surface a protocol error with the holder line.
  session/close, session/delete, and closeAll release; delete releases
  before removing files so no lock sidecar is left behind on Windows.
- controller: export ResolveBranchRef (wrapper over resolveBranch) so
  the TUI can learn a /switch target's path before moving its lease.
- Crash leftovers need no special casing: the OS lock is the sole
  arbiter, so a dead holder's lease is simply acquirable.

Not wired in this round (documented in #6027): the bot gateway's
attached-session path (needs desktop-style lease handover semantics)
and destructive-op guards for serve/ACP delete endpoints.

Refs #6027.

Cache-impact: none - CLI/serve/ACP session binding and help text only;
no provider-visible prompts, tool schemas, or request serialization.
2026-07-06 02:15:20 +08:00
SivanCola 075b961254 Merge pull request #6030 from SivanCola/fix/lease-experience-cleanup
Polish lease UX: self-heal metadata damage, sanitize errors, retire lock sidecars / 打磨租约体验:元数据损坏自愈、错误脱敏、锁侧车清理
2026-07-06 01:03:53 +08:00
SivanCola 8ea1c4905b Merge pull request #6023 from SivanCola/fix/desktop-lease-key-canonicalization
Fix Windows desktop session lease self-lock on model switch / 修复 Windows 桌面端切换模型会话租约自锁
2026-07-06 00:53:27 +08:00
merge-order-check cce4bfa2bb fix(desktop): release replaced-tab resources when session clear loses the swap
clearActiveSessionRuntime's swap-time identity check silently dropped
the freshly built controller when the tab had been closed or replaced
while the clear ran off-lock: newCtrl was never closed and the session
lease it had just bound stayed held by the abandoned tab struct.

Mirror rebuildSettingLocked's failure branch: close the fresh
controller, release the lease, still finish the destroyed runtime's
teardown, and report the lost swap instead of returning success.
2026-07-06 00:24:36 +08:00
SivanCola 8ab68bf0fc Merge pull request #6025 from SivanCola/fix/session-meta-cas-hardening
Harden session CAS meta ledger against torn reads and lost updates / 加固会话 CAS 元数据账本防撕裂读与丢失更新
2026-07-06 00:10:40 +08:00
SivanCola 6fae377e2f Merge pull request #6029 from SivanCola/fix/replacefile-no-torn-fallback
fix(fileutil): keep ReplaceFile atomic under transient rename failures / 瞬时重命名失败时保持 ReplaceFile 的原子发布
2026-07-06 00:10:10 +08:00
merge-order-check 5ac17b55f0 fix(desktop): supersede in-flight startup build on provider removal and session clear
Two more sync-swap paths shared the race fixed for settings rebuilds:

- removeBuiltInProviderAccessAndRetargetTabs and
  deleteProviderAndRetargetTabs cleared tab.Ctrl without bumping the
  build generation, so a startup build planned against the removed
  provider could finish later, pass its generation check, and reinstall
  a controller for it - racing the generation-0 retarget rebuild.
- clearActiveSessionRuntime installed the fresh controller without
  invalidating an in-flight build, whose completion would overwrite the
  swap and resurrect a runtime bound to the just-destroyed session.

Supersede the build inside the same critical sections that perform the
swap, and extract a shared assertion helper for the regression tests
covering all four protected paths.
2026-07-06 00:09:40 +08:00
SivanCola 4065a6218b Merge pull request #5937 from SivanCola/feature/windows-native-sandbox
Add native Windows sandbox helper / 添加原生 Windows 沙箱 helper
2026-07-06 00:03:12 +08:00
SivanCola c33c372a2f fix(desktop): self-heal lease metadata damage, sanitize surfaced errors, retire lock sidecars
Follow-up polish on the lease/CAS stack after the #6023/#6025 hotfixes,
closing the remaining ways a healthy single-window session could still
present as busy, leak holder details, or litter the session directory.

- Reclaim now arbitrates on the OS lock alone: a missing or unreadable
  lease.json (user cleanup, AV quarantine, crash tear) with a free lock is
  a leftover, not a holder. Previously an orphaned in-process entry whose
  info file disappeared could never be reclaimed - the fallback acquire
  re-hit the orphaned entry forever and every rebuild stayed busy. The
  desktop gate mirrors this: nil lease info allows the reclaim attempt,
  foreign holders are still refused.
- Raw lease errors (session path + host-pid-writer id) can no longer reach
  the UI: startup bind failures (StartupErr feeds the topbar banner and tab
  metas), ClearSession's bridge returns, and the recovery-lease callback
  (chat notices via snapshot errors) all render the user-facing busy
  message; raw details stay in slog. The busy message drops its dangling
  'before changing this setting' clause when no setting applies.
- SessionLease.Release retires the .lock/.lease.lock sidecars through the
  existing atomic take-then-remove helpers (non-blocking, no-op against any
  live holder or in-flight save), so ordinary use stops accumulating lock
  files that previously waited for the next boot reconcile (#6014).
- Autosave failures warn once per burst and at most once per 5 minutes per
  tab: retries are slog-only, the user notice fires when the burst gives
  up. Explicit action saves keep immediate feedback. Every failure is still
  logged.
- SaveRecoveryBranch tolerates event-index write failures like the other
  save paths: the recovery transcript and meta are already durable, so a
  failed listing accelerator no longer discards a successful recovery.
- Lease test suite now feeds user-shape (mixed-case) paths to the APIs
  under test instead of pre-canonicalized ones - the pattern that let the
  Windows case-fold mismatch (#5999) escape the suite - plus regressions
  for orphan-without-info, corrupt-info, foreign-info-refused, sidecar
  retirement on release, StartupErr sanitization, and the autosave warning
  debounce.

Refs #6014 #5999 #6027.
2026-07-05 23:59:24 +08:00
SivanCola 1054c188d3 Bump windows-sandbox for run-local residue markers 2026-07-05 23:54:13 +08:00
SivanCola bfe416b706 fix(desktop): supersede in-flight startup build on settings rebuild
rebuildSettingLocked installed its controller without bumping the tab's
build generation, so a startup build still in flight would later pass
its generation check, overwrite the just-installed controller, and leak
it - leaving the session on a runtime built before the new credentials
were saved. Provider-key saves hit this window most often: users
typically save a key right while the initial startup build is running.

Apply the same supersede-after-successful-swap the model/effort/token
switches use, and add a regression test that models an in-flight build
(live generation plus cancellable build context) and asserts the rebuild
supersedes and cancels it.
2026-07-05 23:51:25 +08:00
SivanCola 728d4af4ce fix(session): heal stale meta ledger on same-content saves
A save that lands its transcript bytes and then fails to record the
revision (fail-closed recordSessionContentRevision, or a crash between
the two writes) leaves the sidecar describing older content. The
same-content retry then took the up-to-date shortcut and never touched
the ledger again, so revision/content_digest stayed stale until the
next content-bearing save — contradicting the "later save can bump the
revision" recovery the fail-closed path promises. Idle sessions never
healed at all.

checkSnapshotWrite now surfaces the ledger digest it already reads, and
the up-to-date path records the deferred revision when that digest no
longer matches the on-disk transcript, reproducing the exact state the
interrupted save would have left. A missing or digest-less legacy
sidecar stays untouched: stamping those would bump revisions other
runtimes still hold as baselines.
2026-07-05 23:48:32 +08:00
SivanCola df181876f3 Merge pull request #6004 from HaoyueQin/feat/isolated-runtime-config-override
feat(config): support REASONIX_HOME for fully isolated runtimes / 支持 REASONIX_HOME 完全隔离运行时
2026-07-05 23:44:03 +08:00
SivanCola 7ea789d93b docs: sync isolated home Chinese path guide 2026-07-05 23:38:44 +08:00
SivanCola 34a337ac0f fix(fileutil): never fall back to a torn in-place copy on transient rename failures
ReplaceFile fell back to copyOnto after exhausting its rename retries,
regardless of why the rename failed. copyOnto truncates dest in place,
so a reader racing the copy observes an empty or half-written file —
silently breaking the atomic-publication contract every AtomicWriteFile
caller relies on (session leases, credentials, plugin state, mcp.json).
CI caught it once the window lined up: TestStateLoadDuringSaveNeverSees
TornFile failed on windows-latest with 'unexpected end of JSON input'
after a transient dest lock outlived the ~0.7s retry budget and the
copy truncated the state file under the reader.

Split the failure classes. Transient locks (antivirus, search indexer,
a reader without delete sharing) now retry longer — 12 attempts, ~1.5s
of backoff — and then surface the rename error; a loud failure beats a
silently torn file. The cross-device class (ERROR_NOT_SAME_DEVICE /
EXDEV, reported by encryption-software filter drivers even for a
same-dir rename, #2696) still takes the copy fallback because rename is
structurally impossible there — but now immediately, instead of first
sleeping through a retry ladder that cannot succeed, which also cuts
~0.7s of latency from every save on such hosts.

The rename seam (renameFile) exists because neither failure class can
be provoked portably on a real filesystem; the new tests pin dest
integrity plus the surfaced error for the transient class, and the
immediate no-retry copy for the cross-device class.
2026-07-05 23:32:53 +08:00
SivanCola 1cc80b8100 Merge remote-tracking branch 'origin/main-v2' into pr6004-isolated-runtime-config-override
# Conflicts:
#	docs/CONFIG_PATHS.md
2026-07-05 23:23:45 +08:00
SivanCola 4c6dc74500 fix: keep implicit legacy migration out of isolated homes 2026-07-05 23:22:36 +08:00
SivanCola 7e2d115df3 Merge pull request #6024 from SivanCola/feature/task-contract-credentials-path
feat: add task contracts and stabilize credential path
2026-07-05 23:08:18 +08:00
SivanCola a8e9421bf9 Bump windows-sandbox to survive marker PID reuse
Picks up b79e9a5: the crash-residue marker is keyed by PID alone, so a
helper process that drew a crashed predecessor's PID used to skip the
stale marker in its run-start sweep (pid == self), append its own lines
into the same file, and then delete the whole file on exit — orphaning
the predecessor's forbid_read deny or tool-directory grant beyond any
future sweep's reach. The library now tracks marker ownership
in-process: cleanup only deletes a marker this process wrote, and an
unowned file at the run's own PID is consumed at run start as a dead
predecessor's residue. The filename stays <pid>.txt so older binaries
sharing the marker directory keep parsing PIDs correctly during
upgrade windows.
2026-07-05 23:06:13 +08:00
SivanCola 88598c8fc3 docs: clarify state root paths 2026-07-05 22:58:26 +08:00
SivanCola e6d574587a fix(session): harden the CAS meta ledger against torn reads and lost updates
The transcript CAS keyed off the .jsonl.meta sidecar, but the ledger was
maintained unprotected. On Windows this manufactured conflicts in normal
single-window use: recovery branches forked on every app start, recovery
files accumulated, and 'adopted the newer transcript' notices looped.

- Retry transient sidecar reads (I/O and JSON-decode failures, 20/50/100ms)
  before giving up: ReplaceFile's non-atomic copyOnto fallback tears
  concurrent readers, and AV/indexer handles fail reads transiently.
- Stop swallowing unreadable-ledger errors into revision 0: a persistently
  unreadable ledger now fails the save (transcript bytes are already
  durable; the autosave retry heals it) instead of desyncing the baseline
  and misreading the next honest save as a foreign write.
- LoadSession with an unreadable sidecar still opens the session but marks
  the baseline revision-unknown: revision CAS is disarmed (digest/prefix
  checks still catch real divergence) and owned rewrites anchor on
  digest+version, so compaction cannot become a permanent conflict. A
  missing sidecar remains a known revision-0 baseline.
- Hold the per-path meta lock across the whole read-modify-write in
  RenameSession, topic-title propagation, legacy topic migration, and
  restoreSessionTopicIndex, so a concurrent save's revision bump can no
  longer be rolled back by a stale read-back.
- Downgrade event-index write failures to a warning at both save() sites:
  the index is a listing accelerator and must not strand the baseline
  behind a transcript+revision that already landed.
- Capture save() snapshots under the save locks so concurrent in-process
  savers can no longer land out of order and misread the newer transcript
  as a stale-prefix conflict.

Regression tests cover fail-closed saves on a corrupt ledger, unknown-
revision baselines (load, append, owned rewrite), missing-meta behavior,
index-failure tolerance, a concurrent writers hammer asserting revision
monotonicity, and deterministic under-lock capture.

Fixes #6014. Fixes #5998. Fixes #5996. Refs #6003.
2026-07-05 22:34:43 +08:00
SivanCola 98a1fb29a0 feat: add task contracts and stabilize credential path 2026-07-05 22:24:48 +08:00
SivanCola aa503bca85 fix(desktop): unify session runtime keys with lease canonicalization
The lease registry keys sessions through agent's canonical form (clean,
absolute, lowercased on Windows), while desktop sessionRuntimeKey kept the
raw path case. On Windows every tab path contains uppercase segments
(C:\Users\...), so the tab's own lease never matched its session key:
ensureSessionLease re-acquired against the process's own registry entry,
reclaim then hit the tab's live OS lock handle, and every model / effort /
token-mode / provider-key rebuild failed with the "already open in another
Reasonix window" error on a single window — including fresh installs.

- Export agent.CanonicalSessionPath and layer it into sessionRuntimeKey so
  desktop identity checks agree with the lease registry byte-for-byte.
- Normalize the canonicalTabSessionPath fallback (project-scope sessions)
  with clean+abs so shape variants cannot split one file into two keys.
- Keep case-folded keys out of SessionPath: detach/attach paths now carry
  the display path separately from the runtime key.
- Release only the build's own lease on startup-build failure, and skip
  claiming a lease the build merely reused (a concurrent switch owns it).
- Supersede in-flight startup builds when a model/effort/token switch
  installs its controller, so a slow blank-session build can no longer
  overwrite the new runtime or strip its lease.
- Regression tests: lease/runtime key agreement, idempotence, Windows case
  folding, project-scope fallback normalization, fast-path lease reuse.

Fixes #5999. Fixes #6006. Fixes #5989. Fixes #5990. Refs #5996.
2026-07-05 22:17:38 +08:00
SivanCola 98166cbc60 Merge pull request #6001 from HUQIANTAO/fix/disable-summ-from-on-last-turn
fix(desktop): disable "Summarize after this" on the last message
2026-07-05 22:11:00 +08:00
SivanCola bd045b0dfd Bump windows-sandbox to harden the residue sweep
Picks up the follow-up (3058fd1) that re-checks the system-root
invariant at sweep time: the crash-residue marker lives in the user's
%TEMP% and is untrusted, so a legacy or planted line naming System32 /
Program Files could previously drive the next run's sweep to strip the
broad built-in package SIDs from a system directory's factory ACLs. The
sweep now refuses system paths independently of the recorder-side
filter.
2026-07-05 22:09:21 +08:00
SivanCola 0bf3c44a11 Fix last summary turn detection
Use the same authoritative question turn mapping for the last-turn check that TurnActions uses for rewind actions. This keeps "Summarize after this" disabled on the last visible turn even when restored or paged history uses non-contiguous checkpoint turns.

Co-authored-by: SivanCola <[email protected]>
2026-07-05 19:56:04 +08:00
HaoyueQin faaa43d459 test(boot): update legacy session migration test for REASONIX_HOME isolation
When REASONIX_HOME is set, legacy session migration is skipped to
prevent production data leaking into the isolated environment. Update
TestBuildMigratesLegacyXDGAndProjectSessions to verify this behavior.
2026-07-05 17:12:05 +08:00
HaoyueQin a238ace545 feat: support REASONIX_HOME for fully isolated runtime environments
When REASONIX_HOME is set, the runtime is now fully self-contained: all
configuration, state, cache, skills, commands, hooks, trust, credentials,
migration, and desktop tab/project data live under that directory tree.
Legacy fallback paths and auto-migration are skipped so a source-built
binary can run side-by-side with a stable release without interfering.

Changes:
- config/paths.go: add IsolatedHomeDir(), gate legacyOSSupportDir /
  legacyXDGConfigPaths, make userCacheDir() fall back to
  REASONIX_HOME/cache when isolated
- config/migrate.go: skip MigrateLegacyIfNeeded when isolated
- config/mcpjson.go: skip legacyConfigPath when isolated
- config/ccswitch.go: skip LoadCCSwitchMCP when isolated
- config/credentials.go: skip ~/.env credential source when isolated
- skill/skill.go: skip OS-home convention dir scan when isolated
- hook/hook.go: skip legacy reasonix home fallback when isolated
- migration/migration.go: skip auto migration entry points when isolated
- outputstyle/outputstyle.go: skip home dir scan when isolated
- desktop/tabs.go: desktopConfigDir / globalWorkspaceRoot now use
  config.ReasonixHomeDir() instead of raw os.UserConfigDir()
- desktop/updater.go: defaultUpdateCacheBaseDir uses config.CacheDir()
- CONTRIBUTING.md: document isolated development workflow
- docs/CONFIG_PATHS.md: document full isolation behavior

Closes #5988
2026-07-05 17:00:29 +08:00
HUQIANTAO ab25819640 fix(desktop): disable "Summarize after this" on the last message
The "Summarize after this" (summ-from) button was enabled on the last
user message even though there is nothing after it to summarize. This
mirrors the existing check that disables "Summarize before this" on
the first message (turn <= 0).

- Add isLastTurn prop to TurnActions component
- Check isLastTurn in actionDisabledReason for summ-from scope
- Pass isLastTurn from Transcript hot zone and WarmTurnItems
- Add i18n key rewind.disabledNoLater (en/zh/zh-TW)

Closes #5744
2026-07-05 16:49:01 +08:00
YHH 5c18d06e1e Merge pull request #5997 from esengine/fix/desktop-release-not-latest
fix(ci): keep the repository-wide latest badge on the CLI release
2026-07-05 16:18:18 +08:00
esengine 36c9a44728 fix(ci): keep the repository-wide latest badge on the CLI release
release-desktop.yml's header reserves GitHub's repository-wide "latest"
for the CLI line, but gh release create was not passing --latest=false,
so whichever line published second won the badge — desktop-v1.17.0
stole it this cycle (fixed by hand with gh release edit). Pin desktop
releases to --latest=false so the badge stays on the v* line.
2026-07-05 01:07:14 -07:00
YHH b9612efc5f Merge pull request #5995 from esengine/fix/release-smoke-via-r2-api
fix(ci): smoke release pointers via authenticated R2 S3 API
2026-07-05 15:56:04 +08:00
esengine 9d1925ee6e fix(ci): smoke release pointers via authenticated R2 S3 API
dl.reasonix.io (Cloudflare) persistently serves 403 to GitHub Actions
egress IPs, so every stable release run failed at the public-edge curl
steps even though the publish itself succeeded, and the failure skipped
the two real follow-up steps (attach compatibility latest.json to the
CLI release, refresh the site version).

Read the mirrored manifests and head the platform objects over the
authenticated S3 endpoint instead:

- release-desktop.yml smoke: aws s3 cp / s3api head-object against the
  bucket, mapping each public URL back to its object key. Verifies the
  mirror landed; the public edge is unreachable from CI by Cloudflare
  policy and is exercised by real user traffic.
- release.yml compatibility asset: fetch latest/latest.json via S3
  using the same R2 secrets, skipping cleanly when they are absent
  (matching the desktop workflow's HAS_R2 guard).
2026-07-05 00:49:39 -07:00
SivanCola 39bfc0b045 Bump windows-sandbox to never mutate system executable dirs
Picks up two upstream fixes: exe-dir contention coverage with the
close of the deny-residue window (2a86cf8, 0e09612), and the follow-up
that stops grantAppContainerExecutable from snapshotting, granting, or
recording crash-residue on system directories (1a3e77e) — previously a
crashed elevated run could leave a residue marker that made the next
run strip built-in package SIDs from System32 / Program Files ACLs.
2026-07-05 14:48:13 +08:00
SivanCola 2ecc3bbee0 Bump windows-sandbox for per-root lock thread pinning
Pick up the follow-up that pins the per-root lock to one OS thread, so a Go
goroutine migrating threads cannot release a Windows mutex from a non-owning
thread or re-enter it within the process. Cross-process serialization (each
sandboxed command is its own helper process) was already correct; this
tightens the within-process semantics the library's unit tests cover.
2026-07-05 14:44:28 +08:00
SivanCola ece9c41f8f Bump windows-sandbox with ACL lifecycle hardening
Update the native Windows sandbox backend to the release that fixes the
mutation-based-isolation failure modes: recursive icacls timeout on large
workspaces, residual Low integrity labels after cleanup, concurrent runs
corrupting each other's ACL boundary, forbid_read deny residue after a crash,
forbid_read entries pointing at files being silently unprotected, and
PATH-resolved icacls/taskkill.

Correct the docs to match the actual Windows boundary: writable low-integrity
commands can still write the narrow set of locations Windows leaves writable to
any low-integrity process (e.g. AppData\LocalLow) in addition to the configured
roots, concurrent commands on a shared workspace are serialized, forbid_read
now covers files, and crash residue is swept by the next run.
2026-07-05 14:44:28 +08:00
SivanCola 1071bc41e7 Fix Windows sandbox wait env var 2026-07-05 14:44:28 +08:00
SivanCola 52f1b7535e Test Windows sandbox helper integration 2026-07-05 14:44:28 +08:00
SivanCola f541bd353a Bump Windows sandbox test coverage dependency 2026-07-05 14:44:28 +08:00
SivanCola a7582e3d20 Add native Windows sandbox helper 2026-07-05 14:44:28 +08:00
GTC2080 c146456d8a fix(cli): repaint wide-character input on Windows 2026-07-01 20:15:05 +10:00
225 changed files with 21262 additions and 1106 deletions

No files matched your search

+14
View File
@@ -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:
+67 -5
View File
@@ -9,7 +9,7 @@ name: Release desktop
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
@@ -269,8 +269,14 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
args+=(--prerelease --latest=false)
else
args+=(--latest)
fi
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
@@ -367,6 +373,10 @@ jobs:
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
@@ -374,8 +384,11 @@ jobs:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
@@ -384,7 +397,8 @@ jobs:
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
@@ -393,14 +407,62 @@ jobs:
pointer="latest"
fi
if [ -n "$pointer" ]; then
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
echo "prerelease — no pointer moved; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "canary" ] && chan="canary"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
+30 -4
View File
@@ -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
+105 -2
View File
@@ -30,7 +30,9 @@ jobs:
name: archives + checksums + homebrew tap
needs: cache-guard
runs-on: ubuntu-latest
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
# repository homepage points users at the installable app. Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
steps:
@@ -53,6 +55,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
case "$TAG" in
@@ -61,10 +66,108 @@ jobs:
exit 0
;;
esac
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
exit 0
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so read the manifest over the authenticated S3 API instead
# of the public edge.
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the runs race: both workflows wait
# on the release environment independently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm run may still be awaiting its own environment approval;
# release-npm.yml's verify step owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
if [ "$newest" = "$got" ]; then
echo "npm latest -> $got (>= $version) OK"
exit 0
fi
fi
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
exit 0
+1
View File
@@ -58,4 +58,5 @@ homebrew_casks:
release:
prerelease: auto
make_latest: false
name_template: "Reasonix CLI {{ .Tag }}"
+31
View File
@@ -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
View File
@@ -131,6 +131,8 @@ commands, `@` references, and two-model setup are all in the
from the desktop app, then use approvals, YOLO, and commands from IM.
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
data types, and roadmap.
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
complex requests with context, output boundaries, constraints, and when to ask.
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
names, read-only flags, and schema snapshot guard.
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
+1
View File
@@ -120,6 +120,7 @@ provider key 的运行时 fallback,但仍会作为当前 workspace 范围内
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
+6 -5
View File
@@ -119,9 +119,10 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
and attaches the current desktop manifest to the matching CLI release for old
clients that still ask GitHub's repository-wide `latest` release for it.
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
@@ -133,8 +134,8 @@ The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
the CLI release line. Self-update behavior by platform:
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
+211 -25
View File
@@ -111,6 +111,14 @@ type App struct {
activeTabID string
readyHook func()
// tabsRestored is closed when restoreOrBuildTabs has finished populating
// a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
// work that inspects "which sessions are open" or persists the tab list
// (recovery GC's DeleteSession does both) must wait on it: running against
// the pre-restore empty tab map would treat every saved tab's session as
// closed and could overwrite desktop-tabs.json with an empty snapshot.
tabsRestored chan struct{}
// projectTreeChangedHook is test-only: set once before any concurrency
// starts, then read lock-free from emitProjectTreeChanged (whose callers
// may or may not hold a.mu, so it cannot re-lock). Never write it after
@@ -397,11 +405,17 @@ func (a *App) startup(ctx context.Context) {
a.heartbeat = newHeartbeatEngine(a)
a.heartbeat.Start()
a.mu.Lock()
a.tabsRestored = make(chan struct{})
a.mu.Unlock()
go a.restoreOrBuildTabs()
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
a.goSafe("sendStartupPing", a.sendStartupPing)
a.goSafe("flushMetrics", a.flushMetrics)
a.goSafe("flushPendingCrash", a.flushPendingCrash)
// After restoreOrBuildTabs is launched: the GC's first sweep waits on
// tabsRestored so it never observes the pre-restore empty tab map.
a.startRecoveryGC()
}
func (a *App) beforeClose(ctx context.Context) bool {
@@ -478,9 +492,38 @@ func (a *App) trayReadySignal() <-chan struct{} {
return a.tray.ready
}
// markTabsRestored closes the tabsRestored gate exactly once. Safe when the
// channel was never created (tests that drive App without startup).
func (a *App) markTabsRestored() {
a.mu.Lock()
defer a.mu.Unlock()
if a.tabsRestored == nil {
return
}
select {
case <-a.tabsRestored:
default:
close(a.tabsRestored)
}
}
// tabsRestoredSignal returns a channel closed once tab restore has completed.
// When startup never armed the gate (tests), it reports already-restored.
func (a *App) tabsRestoredSignal() <-chan struct{} {
a.mu.RLock()
defer a.mu.RUnlock()
if a.tabsRestored == nil {
closed := make(chan struct{})
close(closed)
return closed
}
return a.tabsRestored
}
func (a *App) showMainWindow() {
if a.ctx != nil {
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
a.kickDeferredRebuildRetry()
}
}
@@ -546,6 +589,9 @@ func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
// default Global tab on first launch.
func (a *App) restoreOrBuildTabs() {
defer a.recoverToPending("restoreOrBuildTabs")
// Unblock startup work gated on the restore (recovery GC) no matter how
// this returns — including the recover path above.
defer a.markTabsRestored()
// Reap any orphaned codegraph processes from a previous crash or older
// version that leaked them, so they don't accumulate across restarts.
a.reapOrphanCodeGraph()
@@ -558,7 +604,7 @@ func (a *App) restoreOrBuildTabs() {
_, _ = config.MigrateLegacyIfNeeded()
f := loadTabsFile()
_, _ = recoverLegacyProjectSidebarRoots(f)
_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
// Load i18n from the first available config.
@@ -594,7 +640,14 @@ func (a *App) restoreOrBuildTabs() {
tab.effort = cloneStringPtr(entry.Effort)
tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
tab.mode = persistedTabMode(entry.Mode)
tab.goal = strings.TrimSpace(entry.Goal)
// Validate the persisted goal against the session's goal-state
// sidecar: a typed /new or /clear rotates the session through the
// controller without passing App.NewSession/ClearSession, so
// entry.Goal can be stale. Session rotation writes a stopped
// goal-state onto the fresh path; reading it here stops a restart
// from re-seeding the cleared goal into the rotated session. A
// session without a sidecar keeps the persisted goal (legacy).
tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
tab.toolApprovalMode = control.ToolApprovalYolo
@@ -938,12 +991,23 @@ func (a *App) tabReadOnly(tabID string) bool {
func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
a.mu.RLock()
defer a.mu.RUnlock()
tab := a.tabByIDLocked(tabID)
if tab == nil {
a.mu.RUnlock()
return nil, nil
}
return tab, tab.Ctrl
ctrl := tab.Ctrl
retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
a.mu.RUnlock()
if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.tabs[tab.ID] != tab {
return nil, nil
}
return tab, tab.Ctrl
}
return tab, ctrl
}
// activeTabAndCtrl snapshots the active tab and its controller in one locked
@@ -1045,6 +1109,20 @@ func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error
if tab == nil || tab.sink == nil {
return
}
// Autosave fires once per turn; on a persistently failing disk that would
// stream a chat warning after every turn. Rate-limit the user-facing
// notice per tab (the slog line above always records every failure). Saves
// triggered by an explicit action are one-shot and always surface.
if action == "autosave" {
tab.saveMu.Lock()
now := time.Now()
if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
tab.saveMu.Unlock()
return
}
tab.lastAutosaveWarnAt = now
tab.saveMu.Unlock()
}
prefix := "Session autosave failed"
if strings.TrimSpace(action) != "" && action != "autosave" {
prefix = "Session save failed before " + action
@@ -1126,7 +1204,7 @@ func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
if current := a.tabs[tab.ID]; current == tab {
tab.Ctrl = nil
tab.Ready = false
tab.StartupErr = ""
clearTabStartupError(tab)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
@@ -1450,6 +1528,14 @@ func (a *App) NewSession() error {
if err := ctrl.NewSession(); err != nil {
return err
}
// The rotated session starts with zero spend: without this reset the tab
// telemetry keeps the previous session's totals and the status bar 会话费用
// silently turns into an all-sessions running total (#5850).
tab.resetTelemetry(ctrl.SessionPath())
// Mirror the controller: NewSession cleared the active goal, and the tab's
// persisted copy must follow — otherwise the next rebuild/restart would
// re-seed the old goal into the fresh session via SetGoal(tab.goal).
a.clearTabGoal(tab)
a.assignFreshSessionTopic(tab)
a.persistTabSessionPath(tab, ctrl.SessionPath())
a.invalidatePromptHistoryCache()
@@ -1551,14 +1637,32 @@ func (a *App) ClearSession() error {
return err
}
if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
return err
// Wails bridge return: a raw lease error would carry the session path
// and holder id across to the frontend.
return userFacingSessionLeaseError("", err)
}
tab.resetTelemetry()
tab.resetTelemetry(ctrl.SessionPath())
// Mirror the controller: ClearSession cleared the active goal.
a.clearTabGoal(tab)
a.persistTabSessionPath(tab, ctrl.SessionPath())
a.invalidatePromptHistoryCache()
return nil
}
// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
// cannot re-seed a goal the controller has already cleared on session rotation.
func (a *App) clearTabGoal(tab *WorkspaceTab) {
if tab == nil {
return
}
a.mu.Lock()
tab.goal = ""
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
a.mu.Unlock()
}
func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
if tab == nil || oldCtrl == nil {
return fmt.Errorf("workspace is still starting")
@@ -1582,7 +1686,10 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
snap := a.tabRuntimeSnapshot(tab)
oldSink := snap.sink
if oldSink != nil {
oldSink.setBinding(detachedRuntimeTabID(oldPath), nil)
// Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
// derives — a raw path here would hash to a different detached id on
// Windows where keys are case-folded.
oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
oldSink.clearContext()
}
if oldCtrl.RuntimeStatus().Cancellable {
@@ -1643,25 +1750,46 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
newCtrl.EnableInteractiveApproval()
applyTabModeToController(newCtrl, snap.mode)
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
newCtrl.SetGoal(snap.goal)
// Clearing the session clears the active goal too (same contract as
// Controller.ClearSession): the snapshot's goal belongs to the destroyed
// conversation and must not seed the replacement.
path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
if err := tab.ensureSessionLease(path); err != nil {
newCtrl.Close()
return err
// Surfaces through ClearSession's Wails return; keep the holder's
// path/pid/writer id out of it.
return userFacingSessionLeaseError("", err)
}
newCtrl.SetSessionPath(path)
a.mu.Lock()
if current := a.tabs[tab.ID]; current == tab {
tab.Ctrl = newCtrl
tab.sink = newSink
tab.SessionPath = path
tab.Label = newCtrl.Label()
tab.Ready = true
tab.StartupErr = ""
a.saveTabsLocked()
if current := a.tabs[tab.ID]; current != tab {
a.mu.Unlock()
// The old session is already destroyed either way; release what this
// clear acquired for the replaced tab (fresh controller and its
// lease) so neither leaks, and still finish the old runtime teardown.
newCtrl.Close()
tab.releaseSessionLease()
oldCtrl.CloseAfterDestroy()
a.emitProjectTreeChanged()
return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
}
tab.Ctrl = newCtrl
tab.sink = newSink
tab.SessionPath = path
tab.Label = newCtrl.Label()
tab.Ready = true
clearTabStartupError(tab)
tab.goal = ""
// Supersede any in-flight startup build: the session it was resuming
// was just destroyed, and finishing later would pass the generation
// check and overwrite this controller.
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
// Same contract as ClearSession's non-running path: the replacement
// session starts with zero spend.
tab.resetTelemetry(path)
oldCtrl.CloseAfterDestroy()
a.emitProjectTreeChanged()
return nil
@@ -2247,6 +2375,10 @@ func channelDisplayName(provider, domain string) string {
// has an in-process runtime, the runtime is cancelled and removed first so
// autosave cannot recreate or append to the deleted file later.
func (a *App) DeleteSession(path string) error {
return friendlySessionFileError(a.deleteSession(path))
}
func (a *App) deleteSession(path string) error {
dir := a.activeSessionDir()
sessionPath, key, err := validateSessionPath(dir, path)
if err != nil {
@@ -2728,6 +2860,10 @@ func (a *App) activeSessionPath(dir string) string {
// RestoreSession moves a trashed session back into the saved-session list.
func (a *App) RestoreSession(path string) error {
return friendlySessionFileError(a.restoreSession(path))
}
func (a *App) restoreSession(path string) error {
dir, err := a.trashedSessionDir(path)
if err != nil {
return err
@@ -2786,6 +2922,10 @@ func (a *App) sessionOpen(dir, sessionPath string) bool {
// PurgeTrashedSession permanently removes a trashed session and its title/display
// sidecars.
func (a *App) PurgeTrashedSession(path string) error {
return friendlySessionFileError(a.purgeTrashedSession(path))
}
func (a *App) purgeTrashedSession(path string) error {
dir, err := a.trashedSessionDir(path)
if err != nil {
return err
@@ -3015,7 +3155,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
clearTabStartupError(tab)
tab.ActivityStatus = ""
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
a.saveTabsLocked()
@@ -3057,7 +3197,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
clearTabStartupError(tab)
tab.ActivityStatus = ""
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
a.saveTabsLocked()
@@ -4884,6 +5024,15 @@ func (a *App) ContextUsageForTab(tabID string) ContextInfo {
var info ContextInfo
if tab != nil {
// Re-key first: a controller-side rotation (typed /new) may have
// swapped sessions without the App noticing, and the stale totals
// would otherwise be reported — and then persisted — under the new
// session (#5850).
if ctrl != nil {
if sp := ctrl.SessionPath(); sp != "" {
tab.syncTelemetryToSession(sp)
}
}
snap := tab.telemetrySnapshot()
info.SessionTokens = snap.Usage.TotalTokens
info.SessionCost = snap.Usage.SessionCost
@@ -6949,9 +7098,13 @@ type sessionLeaseBusyError struct {
}
func (e *sessionLeaseBusyError) Error() string {
// The raw SessionLeaseError text carries the session path and the
// holder's host-pid-writer id; every user-facing surface must render
// this wrapper instead. An empty setting means the failure gated opening
// the session itself (startup bind), not changing a setting on it.
setting := strings.TrimSpace(e.setting)
if setting == "" {
setting = "this setting"
return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
}
return fmt.Sprintf("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing %s", setting)
}
@@ -6973,6 +7126,25 @@ func userFacingSessionLeaseError(setting string, err error) error {
return err
}
// sessionPathAfterSnapshot returns where a controller rebuild should keep
// persisting after the old controller was snapshotted. Snapshotting is not
// path-neutral: a snapshot conflict can recover by retargeting the controller
// (and the tab's session lease, via handleTabSessionRecovered) to a recovery
// branch, so a prevPath captured before the snapshot may be stale. Reusing the
// stale path would bind the rebuilt controller — carrying the just-recovered
// transcript — back to the original file, turning every later save into a new
// conflict that derives yet another recovery branch. Falls back to fallback
// when the controller is gone or persistence is disabled (empty SessionPath).
func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
if ctrl == nil {
return fallback
}
if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
return path
}
return fallback
}
func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
if err := tab.ensureSessionLease(path); err != nil {
if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
@@ -6994,10 +7166,16 @@ func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path strin
return false
}
var leaseErr *agent.SessionLeaseError
if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil {
if !errors.As(err, &leaseErr) || leaseErr == nil {
return false
}
if leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID() {
// A readable info naming a foreign runtime is respected here; reclaim
// would refuse it anyway. A nil Info (lease.json deleted by the user,
// quarantined by AV, or torn by a crash) must still attempt the reclaim:
// the OS lock is the arbiter there, and refusing on missing metadata
// wedges a session nobody actually holds as permanently busy.
if leaseErr.Info != nil &&
(leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
return false
}
a.mu.RLock()
@@ -7106,6 +7284,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
return err
}
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
carried = oldCtrl.History()
}
@@ -7155,6 +7334,9 @@ func (a *App) SetModelForTab(tabID, name string) error {
tab.model = name
tab.effort = cloneStringPtr(effortOverride)
tab.Label = newCtrl.Label()
// Supersede any in-flight startup build: it would otherwise finish later,
// overwrite this controller, and release/steal the tab's session lease.
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
if oldCtrl != nil {
@@ -7261,6 +7443,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
return err
}
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
carried = oldCtrl.History()
}
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
@@ -7302,8 +7485,9 @@ func (a *App) SetEffortForTab(tabID, level string) error {
tab.model = modelRef
tab.effort = &effort
tab.Label = newCtrl.Label()
tab.StartupErr = ""
clearTabStartupError(tab)
tab.Ready = true
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
if oldCtrl != nil {
@@ -7386,6 +7570,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
return err
}
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
carried = oldCtrl.History()
}
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
@@ -7427,8 +7612,9 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
tab.model = modelRef
tab.tokenMode = mode
tab.Label = newCtrl.Label()
tab.StartupErr = ""
clearTabStartupError(tab)
tab.Ready = true
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
if oldCtrl != nil {
+2 -3
View File
@@ -93,13 +93,12 @@ func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
_ = path
a, tab := appWithTab(t, path)
_, tab := appWithTab(t, path)
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
_ = a
waitForAutosaveIdle(t, tab)
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
+35
View File
@@ -642,6 +642,41 @@ func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
}
}
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
// Race product on disk: deleted topic whose default title lingered in the
// global title map (title-only, absent from GlobalTopics, no sessions).
tombstonedID := "topic_tombstone_blank"
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
t.Fatalf("set lingering title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
meta, err := NewApp().EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == tombstonedID {
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
}
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
+630 -10
View File
@@ -31,6 +31,7 @@ import (
"reasonix/internal/pluginpkg"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -111,6 +112,29 @@ func isolateDesktopUserDirs(t *testing.T) string {
return home
}
func primarySessionFiles(paths []string) []string {
out := make([]string, 0, len(paths))
for _, path := range paths {
if store.IsSessionTranscriptName(filepath.Base(path)) {
out = append(out, path)
}
}
return out
}
func readConflictLogLines(t *testing.T, path string) []string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read conflict log: %v", err)
}
text := strings.TrimSpace(string(data))
if text == "" {
return nil
}
return strings.Split(text, "\n")
}
func setDesktopTestCredential(t *testing.T, key, value string) {
t.Helper()
if _, err := config.SetCredential(key, value); err != nil {
@@ -204,6 +228,7 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
t.Skip("git not installed")
}
isolateDesktopUserDirs(t)
resetWorkspaceGitBranchMetaCacheForTest(t)
repo := t.TempDir()
configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
@@ -252,8 +277,15 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
}
if got.GitBranch != "feature/meta" {
t.Fatalf("gitBranch = %q, want feature/meta", got.GitBranch)
deadline := time.Now().Add(time.Second)
for {
if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
break
}
if time.Now().After(deadline) {
t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch)
}
time.Sleep(10 * time.Millisecond)
}
}
@@ -1355,6 +1387,53 @@ func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
}
}
func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
stepfun, ok := config.CuratedProviderPreset("stepfun")
if !ok || len(stepfun.Entries) != 1 {
t.Fatal("missing stepfun preset")
}
stepfunEntry := stepfun.Entries[0]
stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
if !ok || len(stepfunAnthropic.Entries) != 1 {
t.Fatal("missing stepfun-anthropic preset")
}
stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
view := NewApp().Settings()
for _, id := range []string{"stepfun", "stepfun-anthropic"} {
presetView := providerPresetViewByID(t, view, id)
if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView)
}
}
migrated := config.LoadForEdit(config.UserConfigPath())
stepfunEntryView, ok := migrated.Provider("stepfun")
if !ok {
t.Fatal("stepfun provider missing after migration")
}
if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" {
t.Fatalf("stepfun base_url = %q, want official URL", got)
}
stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic")
if !ok {
t.Fatal("stepfun-anthropic provider missing after migration")
}
if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" {
t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got)
}
}
func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
isolateDesktopUserDirs(t)
preset, ok := config.CuratedProviderPreset("mimo-api")
@@ -1781,6 +1860,65 @@ func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
}
}
func TestSetProviderKeyRebuildSupersedesInFlightStartupBuild(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{{
Name: "old",
Kind: "openai",
BaseURL: "https://example.invalid/v1",
Model: "old-model",
APIKeyEnv: "OLD_MODEL_KEY",
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
// Model the async startup build still being in flight: no controller yet,
// a live build generation, and a cancellable build context.
buildCtx, buildCancel := context.WithCancel(context.Background())
const startupGeneration = 1
tab := &WorkspaceTab{
ID: "tab_key_rebuild",
Scope: "global",
SessionPath: sessionPath,
model: "old/old-model",
buildGeneration: startupGeneration,
buildCancel: buildCancel,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)
if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
t.Fatalf("SetProviderKey: %v", err)
}
if tab.Ctrl == nil {
t.Fatal("provider-key rebuild did not install a controller")
}
defer tab.Ctrl.Close()
assertTabBuildSuperseded(t, app, tab, startupGeneration, buildCtx)
}
func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
@@ -1999,6 +2137,7 @@ func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
sink: &tabEventSink{tabID: "tab_deferred_retry", app: app},
disabledMCP: map[string]ServerView{},
}
installNoopRuntimeEvents(app, tab.sink)
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
@@ -2109,6 +2248,7 @@ func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) {
sink: &tabEventSink{tabID: "tab_pending", app: app},
disabledMCP: map[string]ServerView{},
}
installNoopRuntimeEvents(app, tab.sink)
other := &WorkspaceTab{
ID: "tab_other",
Scope: "global",
@@ -2264,6 +2404,137 @@ func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
}
}
func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{{
Name: "old",
Kind: "openai",
BaseURL: "https://example.invalid/v1",
Model: "old-model",
APIKeyEnv: "OLD_MODEL_KEY",
SupportedEfforts: []string{"low", "max"},
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl")
disk := agent.NewSession("old system prompt")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := disk.Save(recoveryPath); err != nil {
t.Fatalf("save recovery branch: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
meta.Recovered = true
meta.ParentID = "effort-switch-conflict"
meta.RecoveryReason = "snapshot conflict"
meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil {
t.Fatalf("SaveBranchMeta: %v", err)
}
stale := agent.NewSession("old system prompt")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
app := NewApp()
app.ctx = context.Background()
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
tab := &WorkspaceTab{
ID: "tab_depth_cap_effort",
Scope: "global",
SessionPath: recoveryPath,
Ready: true,
model: "old/old-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
oldCtrl := control.New(control.Options{
Executor: oldExec,
SessionDir: dir,
SessionPath: recoveryPath,
Label: "old",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
tab.Ctrl = oldCtrl
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
})
stale.IncrementRewrite()
if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
t.Fatalf("SetEffortForTab: %v", err)
}
if got := tab.Ctrl.SessionPath(); got != recoveryPath {
t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
}
if got := tab.currentSessionPath(); got != recoveryPath {
t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
}
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
}
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
matches = primarySessionFiles(matches)
if len(matches) != 1 || matches[0] != recoveryPath {
t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
}
lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
if len(lines) != 1 {
t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
}
if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
}
if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
}
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot after effort switch recovery: %v", err)
}
afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
if len(afterLines) != len(lines) {
t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines)
}
matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches after snapshot: %v", err)
}
matches = primarySessionFiles(matches)
if len(matches) != 1 || matches[0] != recoveryPath {
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
}
}
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
@@ -2649,6 +2920,108 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
}
}
func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old", "new"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
current := agent.NewSession("old system prompt")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("save current session: %v", err)
}
stale := agent.NewSession("old system prompt")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
app := NewApp()
app.ctx = context.Background()
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
tab := &WorkspaceTab{
ID: "tab_recovery_model",
Scope: "global",
SessionPath: originalPath,
Ready: true,
model: "old/old-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
oldCtrl := control.New(control.Options{
Executor: oldExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "old",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
tab.Ctrl = oldCtrl
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
tab.releaseSessionLease()
})
if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
t.Fatalf("SetModelForTab: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("model switch session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
}
if got := tab.currentSessionPath(); got != recoveryPath {
t.Fatalf("tab current session path = %q, want recovery path %q", got, recoveryPath)
}
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
t.Fatalf("tab lease path = %q, want recovery path %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
}
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
matches = primarySessionFiles(matches)
if len(matches) != 1 || matches[0] != recoveryPath {
t.Fatalf("recovery branches after model switch = %v, want only %q", matches, recoveryPath)
}
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot after model switch recovery: %v", err)
}
matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches after snapshot: %v", err)
}
matches = primarySessionFiles(matches)
if len(matches) != 1 || matches[0] != recoveryPath {
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
}
}
func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
@@ -3557,6 +3930,234 @@ func TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {
}
}
// assertTabBuildSuperseded checks that the startup build registered before the
// mutation (generation) can no longer install its controller and that its
// build context was cancelled.
func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
t.Helper()
app.mu.Lock()
superseded := app.tabBuildSupersededLocked(tab, generation)
app.mu.Unlock()
if !superseded {
t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
}
select {
case <-buildCtx.Done():
default:
t.Fatal("in-flight startup build context was not cancelled")
}
if tab.buildCancel != nil {
t.Fatal("build cancel was not cleared")
}
}
func TestDeleteProviderSupersedesInFlightStartupBuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "prov-b/model-b1"
cfg.Providers = []config.ProviderEntry{
{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
}
cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
// Model the async startup build still being in flight for the affected
// tab: no controller yet, a live generation, a cancellable build context.
buildCtx, buildCancel := context.WithCancel(context.Background())
tab := &WorkspaceTab{
ID: "tab_a",
Scope: "global",
model: "prov-a/model-a1",
buildGeneration: 1,
buildCancel: buildCancel,
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
if err := app.DeleteProvider("prov-a"); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
if tab.model != "prov-b/model-b1" {
t.Fatalf("tab model after delete = %q, want prov-b/model-b1", tab.model)
}
}
func TestRemoveBuiltInProviderAccessSupersedesInFlightStartupBuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "prov-b/model-b1"
cfg.Providers = []config.ProviderEntry{
{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
}
cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
buildCtx, buildCancel := context.WithCancel(context.Background())
tab := &WorkspaceTab{
ID: "tab_ds",
Scope: "global",
model: "deepseek/deepseek-chat",
buildGeneration: 1,
buildCancel: buildCancel,
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
if err := app.RemoveProviderAccess("deepseek"); err != nil {
t.Fatalf("RemoveProviderAccess: %v", err)
}
assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
if tab.model != "prov-b/model-b1" {
t.Fatalf("tab model after access removal = %q, want prov-b/model-b1", tab.model)
}
}
func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{{
Name: "old",
Kind: "openai",
BaseURL: "https://example.invalid/v1",
Model: "old-model",
APIKeyEnv: "OLD_MODEL_KEY",
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
oldSession := agent.NewSession("old system prompt")
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
app := NewApp()
// A runtime is attached while an older async build is still in flight
// (e.g. attached via topic activation); destroying the session must
// invalidate that build so it cannot resurrect the destroyed session.
buildCtx, buildCancel := context.WithCancel(context.Background())
tab := &WorkspaceTab{
ID: "tab_clear",
Scope: "global",
SessionPath: sessionPath,
model: "old/old-model",
Ready: true,
Ctrl: oldCtrl,
buildGeneration: 1,
buildCancel: buildCancel,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)
if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
t.Fatalf("clearActiveSessionRuntime: %v", err)
}
if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
}
defer tab.Ctrl.Close()
assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
}
func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{{
Name: "old",
Kind: "openai",
BaseURL: "https://example.invalid/v1",
Model: "old-model",
APIKeyEnv: "OLD_MODEL_KEY",
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
oldSession := agent.NewSession("old system prompt")
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
app := NewApp()
tab := &WorkspaceTab{
ID: "tab_replaced",
Scope: "global",
SessionPath: sessionPath,
model: "old/old-model",
Ready: true,
Ctrl: oldCtrl,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
// The tab entry now points at a replacement struct (the tab was closed and
// reopened while the clear ran off-lock), so the swap must not apply.
replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(tab.releaseSessionLease)
err := app.clearActiveSessionRuntime(tab, oldCtrl)
if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
}
if replacement.Ctrl != nil {
t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
}
if tab.Ctrl != oldCtrl {
t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
}
if key := tab.sessionLeaseRuntimeKey(); key != "" {
t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
}
}
func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
@@ -5002,7 +5603,7 @@ func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
}
}
func TestDeleteSessionValidTrashRejectsNonEmptyLive(t *testing.T) {
func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -5026,15 +5627,34 @@ func TestDeleteSessionValidTrashRejectsNonEmptyLive(t *testing.T) {
app := NewApp()
app.setTestCtrl(activeCtrl, "")
err := app.DeleteSession(filepath.Base(path))
if err == nil || !strings.Contains(err.Error(), "already exists in trash") {
t.Fatalf("DeleteSession conflict error = %v, want valid trash conflict", err)
if err := app.DeleteSession(filepath.Base(path)); err != nil {
t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("non-empty live session should remain: %v", err)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("live session should be moved out of active history, stat err = %v", err)
}
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("trash session should remain: %v", err)
if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
}
trashed, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("list trash: %v", err)
}
var renamedPath string
for _, candidate := range trashed {
if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
renamedPath = candidate
break
}
}
if renamedPath == "" {
t.Fatalf("renamed trash copy not found in %#v", trashed)
}
if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
}
if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"testing"
"time"
)
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
app := NewApp()
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
failure := errors.New("disk unhappy")
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
first := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if first.IsZero() {
t.Fatal("first autosave warning did not record its timestamp")
}
// Within the window: suppressed, timestamp untouched.
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
second := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !second.Equal(first) {
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
}
// Window expired: warns again.
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(first) {
t.Fatal("expired window did not re-arm the autosave warning")
}
// Explicit user actions are never debounced (state untouched).
app.reportTabSnapshotError(tab, "changing model", failure)
tab.saveMu.Lock()
fourth := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !fourth.Equal(third) {
t.Fatal("action-save warning must not consume the autosave debounce window")
}
}
+153 -6
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -10,18 +11,22 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
)
// deferredRebuildRetryInterval is how often the retry loop probes a held
// session lease. Package-level so tests can shorten it.
var deferredRebuildRetryInterval = 2 * time.Second
const deferredStartupBuildLabel = "__startup__"
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
// runtime could not refresh because the session lease was held by another
// Reasonix process. A single background loop probes the lease and replays the
// rebuild once the other side releases it. The loop only runs after
// enableDeferredRebuildRetry (the wails startup hook); tests that never call
// it get the pending bookkeeping without a background goroutine.
// runtime could not refresh, plus tabs whose initial startup failed, because
// the session lease was held by another Reasonix process. A single background
// loop probes the lease and replays the rebuild once the other side releases
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
// hook); tests that never call it get the pending bookkeeping without a
// background goroutine.
type deferredRebuildState struct {
mu sync.Mutex
pending map[string]string // tab ID -> setting label for notices
@@ -75,6 +80,14 @@ func (a *App) scheduleDeferredRebuild(tabID, setting string) {
a.startDeferredRebuildLoopLocked()
}
func (a *App) scheduleDeferredStartupBuild(tabID string) {
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
}
func isDeferredStartupBuild(setting string) bool {
return setting == deferredStartupBuildLabel
}
func (a *App) clearDeferredRebuild(tabID string) {
d := &a.deferredRebuild
d.mu.Lock()
@@ -127,10 +140,16 @@ func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
// deferredRebuildTickDone runs one retry pass and reports true when the loop
// should exit because nothing is pending anymore.
func (a *App) deferredRebuildTickDone() bool {
return a.deferredRebuildTick(true)
}
func (a *App) deferredRebuildTick(markIdle bool) bool {
d := &a.deferredRebuild
d.mu.Lock()
if d.stopped || len(d.pending) == 0 {
d.running = false
if markIdle {
d.running = false
}
d.mu.Unlock()
return true
}
@@ -146,6 +165,15 @@ func (a *App) deferredRebuildTickDone() bool {
return false
}
func (a *App) kickDeferredRebuildRetry() {
if a.ctx == nil {
return
}
a.goSafe("deferredRebuildKick", func() {
_ = a.deferredRebuildTick(false)
})
}
func (a *App) retryDeferredRebuild(tabID, setting string) {
if a.ctx == nil {
return
@@ -156,6 +184,10 @@ func (a *App) retryDeferredRebuild(tabID, setting string) {
a.clearDeferredRebuild(tabID)
return
}
if isDeferredStartupBuild(setting) {
a.retryDeferredStartupBuild(tabID, tab)
return
}
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
// the session lease, and a concurrent manual rebuild's ensureSessionLease
// would see that probe as "held by another runtime" and spuriously defer.
@@ -200,6 +232,121 @@ func (a *App) retryDeferredRebuild(tabID, setting string) {
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
}
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
a.clearDeferredRebuild(tabID)
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tabID)
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return
}
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
}
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
}
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
buildCtx, cancel := context.WithCancel(a.bootContext())
a.mu.Lock()
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
a.mu.Unlock()
cancel()
return nil
}
if tab.Ctrl != nil {
a.mu.Unlock()
cancel()
return nil
}
if !tab.StartupErrLeaseHeld {
a.mu.Unlock()
cancel()
return nil
}
tab.buildGeneration++
generation := tab.buildGeneration
if tab.buildCancel != nil {
tab.buildCancel()
}
tab.buildCancel = cancel
tab.Ready = false
clearTabStartupError(tab)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
}
a.saveTabsLocked()
a.mu.Unlock()
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
a.mu.RLock()
stillCurrent := false
var ctrl control.SessionAPI
startupErr := ""
leaseHeld := false
if tab != nil {
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
ctrl = tab.Ctrl
startupErr = tab.StartupErr
leaseHeld = tab.StartupErrLeaseHeld
}
a.mu.RUnlock()
if !stillCurrent || ctrl != nil {
return nil
}
if leaseHeld {
return agent.ErrSessionLeaseHeld
}
if strings.TrimSpace(startupErr) != "" {
return fmt.Errorf("session startup: %s", startupErr)
}
return fmt.Errorf("session startup: controller was not built")
}
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
return false
}
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
return a.controllerForTab(tab) != nil
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return false
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tab.ID)
return a.controllerForTab(tab) != nil
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
a.scheduleDeferredStartupBuild(tab.ID)
} else {
a.clearDeferredRebuild(tab.ID)
}
return false
}
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
// could be acquired right now, without touching tab.sessionLease (only the
// serialized rebuild paths may mutate that). The probe path can lag the
+2 -2
View File
@@ -10,9 +10,9 @@
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
},
"dependencies": {
"@gsap/react": "^2.1.2",
+6 -13
View File
@@ -2279,8 +2279,8 @@ export default function App() {
// send wrapper: commits any pending optimistic rewind before sending.
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
if (activeTab?.readOnly) throw new Error("channel session is read-only");
if (!controllerReady) throw new Error("workspace is still starting");
if (activeTab?.readOnly) throw new Error(t("composer.readOnlyChannel"));
if (!controllerReady) throw new Error(t("composer.workspaceStarting"));
const rs = rewindStateRef.current;
if (rs) {
rewindStateRef.current = null;
@@ -2296,7 +2296,7 @@ export default function App() {
// Rewind failed: the Go conversation is intact. Do not send; the
// controller emits a notice with the reason.
setRewindState(null);
throw new Error("rewind failed");
throw new Error(t("rewind.failed"));
}
setRewindSignal((v) => v + 1);
if (rs.scope === "both") {
@@ -2306,7 +2306,7 @@ export default function App() {
}
}
await send(displayText, submitText);
}, [activeTab?.readOnly, controllerReady, send, rewind]);
}, [activeTab?.readOnly, controllerReady, send, rewind, t]);
const handleTranscriptPrompt = useCallback((text: string) => {
if (!controllerReady) return;
@@ -2548,7 +2548,7 @@ export default function App() {
} else {
throw new Error(scope === "global" && !session.topicId
? t("history.failedOpenSession")
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
: (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession")));
}
if (!latest()) return;
seedActiveTabMeta(targetTab);
@@ -2884,13 +2884,6 @@ export default function App() {
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const recoveryBannerTitle = activeTab?.recovered
? [
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
].filter(Boolean).join(" · ")
: "";
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const windowsFramelessChrome = desktopPlatform === "windows";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
@@ -3443,7 +3436,7 @@ export default function App() {
)}
{activeTab?.recovered && !sidebarImDetailConnection && (
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
<div className="banner banner--recovery">
<span className="banner__badge">{t("recovery.branch")}</span>
<span>{t("recovery.banner")}</span>
</div>
@@ -54,7 +54,7 @@ async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000
ok(false, label);
}
function installDom() {
function installDom(language = "en-US") {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -62,6 +62,7 @@ function installDom() {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
@@ -129,7 +130,7 @@ async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]
console.log("\napproval modal file references");
{
const dom = installDom();
const dom = installDom("en-US");
mockApp({
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
SearchFileRefs: async () => [],
@@ -178,6 +179,86 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-approval-zh",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("仅本次不进沙箱运行:go test ./..."), "sandbox escape approval localizes subject in Chinese UI");
ok(text.includes("Windows 沙箱启动这条命令时失败"), "sandbox escape approval localizes English backend reason in Chinese UI");
ok(text.includes("允许一次"), "sandbox escape Chinese approval shows allow once");
ok(text.includes("本会话使用真实环境"), "sandbox escape Chinese approval shows session grant");
ok(text.includes("拒绝"), "sandbox escape Chinese approval shows deny");
ok(!text.includes("总是允许"), "sandbox escape Chinese approval hides persistent grant");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "memory-approval-zh",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("保存记忆"), "remember approval localizes tool label in Chinese UI");
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"), "remember approval localizes subject prefix in Chinese UI");
ok(text.includes("正文: Use Vitest for frontend tests."), "remember approval localizes body label in Chinese UI");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "plan-mode-read-only-command-zh",
tool: "plan_mode_read_only_command",
subject: "Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
reason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("计划模式只读命令"), "plan-mode read-only command approval localizes tool label in Chinese UI");
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"), "plan-mode read-only command approval localizes subject in Chinese UI");
ok(text.includes("不在 Reasonix 内置只读集合中"), "plan-mode read-only command approval localizes reason in Chinese UI");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -260,8 +341,8 @@ console.log("\napproval modal file references");
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|4Deny",
"fresh-human approval keeps allow/deny shortcut keys",
"1Allow once|2Deny",
"fresh-human approval keeps conventional allow/deny shortcut keys",
);
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
@@ -280,5 +361,108 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<[boolean, boolean, boolean]> = [];
const { root } = await renderApproval({
approval: {
id: "memory-approval-deny",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
},
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([[false, false, false]]), "fresh-human numeric 2 denies");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-approval",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
});
const text = document.body.textContent ?? "";
ok(text.includes("bash sandbox escape"), "sandbox escape approval uses a clear tool label");
ok(text.includes("Allow once"), "sandbox escape approval shows allow once");
ok(text.includes("Use real environment for this session"), "sandbox escape approval shows session grant");
ok(text.includes("Deny"), "sandbox escape approval shows deny");
ok(!text.includes("Always allow"), "sandbox escape approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|2Use real environment for this session|3Deny",
"sandbox escape approval keeps conventional allow once/session/deny shortcut keys",
);
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([{ allow: true, session: true, persist: false }]), "sandbox escape Enter on selected session action grants session");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-deny-approval",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([{ allow: false, session: false, persist: false }]), "sandbox escape numeric 3 denies");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -109,6 +109,59 @@ const infoCost = contextCostDisplay({
usage: { cost: 0, costUsd: 0, currency: "¥" },
});
eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
const singleRequestOnly = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 0,
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "¥" },
});
eq(
singleRequestOnly,
{ amount: 0, currency: "¥" },
"a single request's cost never renders under the session-cost label",
);
const localAccumulated = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 1.5,
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
});
eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
console.log("\ncontext panel session cache scope");
eq(
contextSessionCache(
{ sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
{ cacheHitTokens: 800, cacheMissTokens: 200 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 900, miss: 100 },
"all-sources panel telemetry beats executor-only wire counters",
);
eq(
contextSessionCache(
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
{ cacheHitTokens: 800, cacheMissTokens: 200 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 800, miss: 200 },
"ContextInfo telemetry is the second all-sources choice",
);
eq(
contextSessionCache(
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
{ cacheHitTokens: 0, cacheMissTokens: 0 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 700, miss: 300 },
"executor-only wire counters only bridge the pre-refresh gap",
);
eq(
contextSessionCache(null, undefined, undefined),
{ hit: 0, miss: 0 },
"no data renders as empty, not NaN",
);
eq(formatMoney(infoCost.amount, infoCost.currency, "dash"), "$0.1759", "USD panel cost renders with dollar sign");
eq(currencySymbol("楼"), "¥", "unexpected currency text does not leak into money values");
eq(currencySymbol("aud"), "AUD ", "unknown ISO currency codes stay readable");
@@ -0,0 +1,210 @@
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(ready: boolean): TabMeta {
return {
id: "tab-ready",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: "topic-ready",
topicTitle: "Ready race",
sessionPath: "/repo/sessions/ready.jsonl",
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo",
};
}
function meta(ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready event meta sync");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
window.runtime = {
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [tabMeta(backendReady)];
},
MetaForTab: async () => {
metaCalls += 1;
return meta(backendReady);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async () => {
historyCalls += 1;
const messages = await historyGate.promise;
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one history hydration");
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
backendReady = true;
await act(async () => {
for (const handler of readyHandlers) handler("tab-ready");
await flushPromises();
});
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,220 @@
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 100; attempt += 1) {
await act(async () => {
await flushPromises(25);
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
return {
id,
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: `topic-${id}`,
topicTitle: id,
sessionPath: `/repo/sessions/${id}.jsonl`,
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active,
cwd: "/repo",
};
}
function meta(tabId: string, ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
sessionPath: `/repo/sessions/${tabId}.jsonl`,
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready meta reconcile");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
let approvalModeCalls = 0;
const metaTabIds: string[] = [];
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [
tabMeta("tab-ready", backendReady, true),
tabMeta("tab-inactive", true, false),
];
},
MetaForTab: async (tabId: string) => {
metaCalls += 1;
metaTabIds.push(tabId);
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async (tabId: string) => {
historyCalls += 1;
const messages = await historyGate.promise;
return {
messages,
startTurn: 0,
endTurn: messages.filter((message) => message.role === "user").length,
totalTurns: messages.filter((message) => message.role === "user").length,
hasOlder: false,
};
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SetToolApprovalModeForTab: async () => {
approvalModeCalls += 1;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one active-tab history hydration");
eq(listTabsCalls, 1, "startup fetches the active tab once");
backendReady = true;
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
eq(historyCalls, 1, "ready polling does not start another history hydration");
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,35 @@
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
console.log("\nrecovery banner privacy");
const recoveryBannerMatch = /<div className="banner banner--recovery"[\s\S]*?<\/div>/.exec(appSource);
const recoveryBanner = recoveryBannerMatch?.[0] ?? "";
ok(Boolean(recoveryBannerMatch), "App renders a recovery banner");
ok(!recoveryBanner.includes("title="), "recovery banner does not expose internals through a tooltip");
ok(!recoveryBanner.includes("recoveryDigest"), "recovery banner does not expose recovery digest");
ok(!recoveryBanner.includes("recoveryParentId"), "recovery banner does not expose parent session id");
ok(!appSource.includes("recoveryBannerTitle"), "App no longer builds a detailed recovery tooltip");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -8,6 +8,7 @@ import {
SettingsPanel,
formatProviderExtraBody,
parseProviderExtraBody,
providerExtraBodyParseError,
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
@@ -146,6 +147,26 @@ try {
extraBodyRejected = true;
}
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
const extraBodyTestT = ((key: string, vars?: Record<string, string | number>) => {
if (key === "settings.providerExtraBodyError") return "localized extra body fallback";
if (key === "settings.providerExtraBodyNull") return `${vars?.path} localized null`;
return key;
}) as any;
eq(
providerExtraBodyParseError(new SyntaxError("Unexpected token } in JSON"), extraBodyTestT),
"localized extra body fallback",
"extra body editor localizes JSON syntax errors",
);
try {
parseProviderExtraBody('{ "nested": { "value": null } }', extraBodyTestT);
ok(false, "extra body editor rejects localized null validation errors");
} catch (e) {
eq(
providerExtraBodyParseError(e, extraBodyTestT),
"extra_body.nested.value localized null",
"extra body editor keeps localized structured validation errors",
);
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
@@ -577,6 +577,8 @@ await act(async () => {
await metaH.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
historyH = deferred<HistoryMessage[]>();
holdNextHistoryForH = true;
await act(async () => {
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, lastQuestionTurn, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import type { Item } from "../lib/useController";
let passed = 0;
@@ -105,6 +105,22 @@ console.log("\ntranscript grouping contract");
eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
eq(lastQuestionTurn([
{ id: "u0", text: "first", turn: 0 },
{ id: "u1", text: "second", turn: 1 },
], visibleTurns), 1, "last question turn follows visible ordinal fallback");
eq(lastQuestionTurn([
{ id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
{ id: "u1", text: "live without server stamp yet", turn: 1 },
{ id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
], backendTurns), 3, "last question turn follows non-contiguous backend turn");
const pagedTurns = questionTurnsById([
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
]);
eq(lastQuestionTurn([
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
], pagedTurns), 1060, "last question turn supports paged history windows");
}
{
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import type { Meta, TabMeta, WireUsage } from "../lib/types";
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
@@ -82,6 +82,39 @@ function usage(source: string): WireUsage {
console.log("\nuse controller meta");
{
eq(
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"model busy guard is localized",
);
eq(
modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"model lease conflict explains the safe path",
);
eq(
modelSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing models again in a moment.",
"model startup race asks the user to retry later",
);
eq(
modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
"The current session changed while switching models. Try once more.",
"model tab race asks the user to retry",
);
eq(
modelSwitchNoticeText('unknown model "missing"'),
'Unknown model "missing".',
"model unknown error is localized",
);
eq(
modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
'Model "other/other-model" is unavailable because provider "other" is not added.',
"model provider access error is localized",
);
}
{
eq(
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
@@ -110,6 +143,100 @@ console.log("\nuse controller meta");
);
}
{
eq(
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
"Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"token mode busy guard is localized",
);
eq(
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
"The current session changed while switching token saver. Try once more.",
"token mode tab race asks the user to retry",
);
}
{
eq(
localizedBackendNoticeText("Session autosave failed: disk full"),
"Session autosave failed: disk full",
"backend autosave notice is localized through the active dictionary",
);
eq(
localizedBackendNoticeText("Session save failed before changing model: disk full"),
"Session save failed before changing models: disk full",
"backend save-before-action notice localizes the action",
);
eq(
localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
'Model "old/model" is no longer available; switched to new/model.',
"backend model fallback notice is localized",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"legacy recovery branch notice hides internal branch id",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery copy notice is user-facing",
);
eq(
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"legacy repeated recovery conflict notice is user-facing",
);
eq(
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"repeated recovery conflict notice is user-facing",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
"The session changed on disk, so Reasonix adopted the newer transcript.",
"adopted transcript notice is user-facing",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
"covered adopted transcript notice is user-facing",
);
}
{
let s = reducer(initialState, {
type: "event",
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
});
s = reducer(s, {
type: "event",
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
});
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed in the live transcript");
eq(s.seq, 1, "collapsed recovery notice does not consume a sequence id");
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
}
{
const history: HistoryMessage[] = [
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
{ role: "user", content: "continue" },
];
const hydrated = historyMessagesToItems(history, "h");
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
const users = hydrated.items.filter((item) => item.kind === "user");
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed when hydrating history");
eq(users[0]?.kind === "user" && users[0].id, "h1", "collapsed history notice keeps later item ids compact");
eq(hydrated.seq, 2, "collapsed history notice does not inflate the hydrated sequence");
}
{
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
+111 -13
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import gsap from "gsap";
import { useT } from "../lib/i18n";
import { useT, type Translator } from "../lib/i18n";
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
@@ -28,7 +28,89 @@ function animateShelfExit(
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
}
function approvalToolLabel(tool: string, t: Translator): string {
switch (tool) {
case "bash":
return t("approval.toolLabelBash");
case "edit_file":
return t("approval.toolLabelEditFile");
case "write_file":
return t("approval.toolLabelWriteFile");
case "multi_edit":
return t("approval.toolLabelMultiEdit");
case "move_file":
return t("approval.toolLabelMoveFile");
case "web_fetch":
return t("approval.toolLabelWebFetch");
case "run_skill":
return t("approval.toolLabelRunSkill");
case "remember":
return t("approval.toolLabelRemember");
case "forget":
return t("approval.toolLabelForget");
case "sandbox_escape":
return t("approval.toolLabelSandboxEscape");
case "plan_mode_read_only_command":
return t("approval.toolLabelPlanModeReadOnly");
default:
return tool;
}
}
const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
const planModeMcpEnglishSubject = /^MCP (.+) as read-only for planning and research$/;
const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
const trimmed = subject.trim();
if (tool === "sandbox_escape") {
if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
return `${t("approval.sandboxEscapeSubjectPrefix")}${trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length)}`;
}
return trimmed;
}
if (tool === "remember") {
return trimmed
.replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
.replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
}
if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
}
const mcpTrust = trimmed.match(planModeMcpEnglishSubject);
if (mcpTrust) {
return t("approval.planModeMcpTrustSubject", { target: mcpTrust[1] ?? "" });
}
const bashTrust = trimmed.match(planModeBashEnglishSubject);
if (bashTrust) {
return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
}
return trimmed;
}
function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
const trimmed = reason?.trim() ?? "";
if (tool !== "sandbox_escape") return trimmed;
if (trimmed.includes("could not wrap this command")) return t("approval.sandboxEscapeWrapReason");
if (trimmed.includes("failed while starting this command") || trimmed.includes("Run this command unconfined once?")) {
return t("approval.sandboxEscapeRuntimeReason");
}
return trimmed || t("approval.sandboxEscapeRuntimeReason");
}
function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
return t("approval.planModeBashTrustReason");
}
if (reason.includes("external read-only hints need your confirmation")) {
return t("approval.planModeMcpTrustReason");
}
return reason;
}
export function ApprovalModal({
@@ -52,13 +134,14 @@ export function ApprovalModal({
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
const toolLabel = approvalToolLabel(approval.tool, t);
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const subject = approval.subject.trim();
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
const hasToolDetails = Boolean(approval.reason || subject);
const toolMeta = reason || subjectSummary || approval.tool;
const hasToolDetails = Boolean(reason || subject);
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
@@ -100,6 +183,9 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
else if (hasFreshSessionGrant && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (hasFreshSessionGrant && key === "3") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
@@ -107,6 +193,12 @@ export function ApprovalModal({
else if (key === "Escape") answerWithExit(onStop);
};
const selectedToolActionKey = (index: number) => {
if (!isFreshHumanApproval) return String(index + 1);
if (hasFreshSessionGrant) return index === 0 ? "1" : index === 1 ? "2" : "3";
return index === 0 ? "1" : "2";
};
useEffect(() => {
cardRef.current?.focus();
setRevisionOpen(false);
@@ -116,7 +208,7 @@ export function ApprovalModal({
playAttentionChime();
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
@@ -137,8 +229,7 @@ export function ApprovalModal({
event.preventDefault();
const key = String(selectedIndexRef.current + 1);
if (isPlanApproval) choosePlanAction(key);
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
else chooseToolAction(key);
else chooseToolAction(selectedToolActionKey(selectedIndexRef.current));
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
event.preventDefault();
if (isPlanApproval) choosePlanAction(event.key);
@@ -147,7 +238,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) {
@@ -318,7 +409,14 @@ export function ApprovalModal({
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
{isFreshHumanApproval ? (
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
hasFreshSessionGrant ? (
<>
<PromptAction keyLabel="2" label={t("approval.allowSandboxEscapeSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} />
</>
) : (
<PromptAction keyLabel="2" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
)
) : (
<>
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
@@ -331,7 +429,7 @@ export function ApprovalModal({
>
{detailsOpen && (
<div className="approval-details">
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{reason && <div className="approval-reason">{reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
@@ -126,24 +126,40 @@ export function contextCostDisplay({
sessionCurrency?: string;
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
}): { amount: number; currency?: string } {
// Session-scoped sources only: this value renders under the 会话费用 label,
// and falling back to a single request's usage.cost silently displayed one
// turn's spend as the whole session's. usage now contributes currency only.
if (info?.sessionCost && info.sessionCost > 0) {
return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
}
if (sessionCost && sessionCost > 0) {
return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
}
if (usage?.cost && usage.cost > 0) {
return { amount: usage.cost, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
}
if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
}
if (usage?.costUsd && usage.costUsd > 0) {
return { amount: usage.costUsd, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
}
return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
}
// contextSessionCache picks the session-cumulative cache hit/miss pair for the
// panel's session average. All-sources telemetry (panel info, then ContextInfo)
// wins over the wire session counters, which the Go agent scopes to the
// executor only — the same preference StatusBar applies — and the pair always
// comes from a single source so the computed rate never mixes scopes.
export function contextSessionCache(
info?: Pick<ContextPanelInfo, "sessionCacheHitTokens" | "sessionCacheMissTokens"> | null,
context?: Pick<ContextInfo, "cacheHitTokens" | "cacheMissTokens">,
usage?: Pick<WireUsage, "sessionCacheHitTokens" | "sessionCacheMissTokens">,
): { hit: number; miss: number } {
const infoHit = info?.sessionCacheHitTokens ?? 0;
const infoMiss = info?.sessionCacheMissTokens ?? 0;
if (infoHit + infoMiss > 0) return { hit: infoHit, miss: infoMiss };
const ctxHit = context?.cacheHitTokens ?? 0;
const ctxMiss = context?.cacheMissTokens ?? 0;
if (ctxHit + ctxMiss > 0) return { hit: ctxHit, miss: ctxMiss };
return { hit: usage?.sessionCacheHitTokens ?? 0, miss: usage?.sessionCacheMissTokens ?? 0 };
}
interface ContextBreakdown {
promptTokens: number;
completionTokens: number;
@@ -354,11 +370,12 @@ export function ContextPanel({
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
// Session-cumulative values for the top summary.
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
// the Go agent (includes background tasks) and arrives with every usage event.
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
// Session-cumulative cache tokens for the top summary: all-sources telemetry
// first (matching the session cost and per-source rows in this panel — the
// wire session counters are executor-only), with the live counters bridging
// only a fresh session's first turn before the telemetry refresh. Hit and
// miss come as a pair from one source so the rate cannot mix scopes.
const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
@@ -557,6 +557,7 @@ export function TurnActions({
actionPending = false,
rewindDisabled = false,
hoverMenus = false,
isLastTurn = false,
}: {
text: string;
turn?: number;
@@ -567,6 +568,8 @@ export function TurnActions({
actionPending?: boolean;
rewindDisabled?: boolean;
hoverMenus?: boolean;
/** true when this is the last user turn — disables "summarize after" */
isLastTurn?: boolean;
}) {
const t = useT();
const [confirmScope, setConfirmScope] = useState<MessageActionScope | null>(null);
@@ -577,6 +580,9 @@ export function TurnActions({
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
return t("rewind.disabledNoBoundary");
}
if (scope === "summ-from" && isLastTurn) {
return t("rewind.disabledNoLater");
}
if (scope === "summ-upto") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
@@ -162,11 +162,11 @@ export function SettingsPanel({
setWarning(result.trim());
}
} catch (e) {
setErr(String((e as Error)?.message ?? e));
setErr(formatSettingsError(e, t));
} finally {
setBusy(false);
}
}, [reload, onChanged]);
}, [reload, onChanged, t]);
const backgroundApply = useCallback(async (fn: () => Promise<void>) => {
setErr(null);
setWarning(null);
@@ -175,9 +175,9 @@ export function SettingsPanel({
const next = await reload();
onChanged(next);
} catch (e) {
setErr(String((e as Error)?.message ?? e));
setErr(formatSettingsError(e, t));
}
}, [reload, onChanged]);
}, [reload, onChanged, t]);
const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
const snapped = snapZoom(zoom);
const seq = ++zoomSaveSeq.current;
@@ -189,10 +189,10 @@ export function SettingsPanel({
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
} catch (e) {
if (seq !== zoomSaveSeq.current) return;
setErr(String((e as Error)?.message ?? e));
setErr(formatSettingsError(e, t));
setZoomPct(zoomToPercent(getRestartZoom()));
}
}, []);
}, [t]);
// Close on Esc
useEffect(() => {
@@ -769,17 +769,38 @@ function sortedJSONValue(value: unknown): unknown {
return value;
}
function validateProviderExtraBodyValue(value: unknown, path = "extra_body"): void {
function formatSettingsError(error: unknown, t: ReturnType<typeof useT>): string {
const msg = String((error as Error)?.message ?? error ?? "").trim();
const unknownModel = /^unknown model (.+)$/i.exec(msg);
if (unknownModel) return t("settings.errorUnknownModel", { model: unknownModel[1] });
const providerNotAdded = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
if (providerNotAdded) return t("settings.errorModelProviderMissing", { model: providerNotAdded[1], provider: providerNotAdded[2] });
const providerNoKey = /^model (.+) is not available because provider (.+) has no key$/i.exec(msg);
if (providerNoKey) return t("settings.errorModelProviderNoKey", { model: providerNoKey[1], provider: providerNoKey[2] });
const removeAccessBusy = /^finish or cancel active work using (.+) before removing the provider access$/i.exec(msg);
if (removeAccessBusy) return t("settings.errorRemoveAccessBusy", { provider: removeAccessBusy[1] });
const deleteProviderBusy = /^finish or cancel active work using (.+) before deleting the provider$/i.exec(msg);
if (deleteProviderBusy) return t("settings.errorDeleteProviderBusy", { provider: deleteProviderBusy[1] });
const saveBeforeRemoveAccess = /^save current session before removing provider access: (.+)$/is.exec(msg);
if (saveBeforeRemoveAccess) return t("settings.errorSaveBeforeRemoveAccess", { err: saveBeforeRemoveAccess[1] });
const saveBeforeDeleteProvider = /^save current session before deleting provider: (.+)$/is.exec(msg);
if (saveBeforeDeleteProvider) return t("settings.errorSaveBeforeDeleteProvider", { err: saveBeforeDeleteProvider[1] });
const removeProviderUsed = /^remove provider: (.+) is used by open tabs and no other configured provider exists$/i.exec(msg);
if (removeProviderUsed) return t("settings.errorRemoveProviderNoFallback", { provider: removeProviderUsed[1] });
return msg || t("settings.errorUnknown");
}
function validateProviderExtraBodyValue(value: unknown, path = "extra_body", t?: ReturnType<typeof useT>): void {
if (value === null) {
throw new Error(`${path} cannot contain null`);
throw new Error(t ? t("settings.providerExtraBodyNull", { path }) : `${path} cannot contain null`);
}
if (Array.isArray(value)) {
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`));
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`, t));
return;
}
if (typeof value === "object") {
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
validateProviderExtraBodyValue(child, `${path}.${key}`);
validateProviderExtraBodyValue(child, `${path}.${key}`, t);
}
}
}
@@ -795,14 +816,14 @@ export function formatProviderExtraBody(extraBody: Record<string, unknown> | nul
return JSON.stringify(sortedJSONValue(cleaned), null, 2);
}
export function parseProviderExtraBody(raw: string): Record<string, unknown> {
export function parseProviderExtraBody(raw: string, t?: ReturnType<typeof useT>): Record<string, unknown> {
const trimmed = raw.trim();
if (!trimmed) return {};
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("extra body must be a JSON object");
throw new Error(t ? t("settings.providerExtraBodyObjectRequired") : "extra body must be a JSON object");
}
validateProviderExtraBodyValue(parsed);
validateProviderExtraBodyValue(parsed, "extra_body", t);
const out: Record<string, unknown> = {};
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
const key = rawKey.trim();
@@ -811,6 +832,12 @@ export function parseProviderExtraBody(raw: string): Record<string, unknown> {
return out;
}
export function providerExtraBodyParseError(error: unknown, t: ReturnType<typeof useT>): string {
if (error instanceof SyntaxError) return t("settings.providerExtraBodyError");
const message = String((error as Error)?.message ?? error ?? "").trim();
return message || t("settings.providerExtraBodyError");
}
function providerModelFetchFallbackMessage(error: unknown, t: ReturnType<typeof useT>): string {
const message = String((error as Error)?.message ?? error);
if (/\bstatus\s+(401|403)\b/i.test(message)) {
@@ -5591,9 +5618,9 @@ function ProviderEditor({
const effectiveHeaders = parseProviderHeaders(headersDraft);
const extraBodyParse = useMemo(() => {
try {
return { value: parseProviderExtraBody(extraBodyDraft), error: "" };
} catch {
return { value: {}, error: t("settings.providerExtraBodyError") };
return { value: parseProviderExtraBody(extraBodyDraft, t), error: "" };
} catch (e) {
return { value: {}, error: providerExtraBodyParseError(e, t) };
}
}, [extraBodyDraft, t]);
const effectiveExtraBody = extraBodyParse.value;
@@ -6218,7 +6245,7 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
const parseHooksEditorJSON = (raw = jsonText): { hooks: HookConfigView[]; text: string } | null => {
try {
const hooks = parseHooksJSON(raw, view?.events ?? []);
const hooks = parseHooksJSON(raw, view?.events ?? [], t);
const text = formatHooksJSON(hooks, view?.events ?? []);
setJsonText(text);
setJsonError(null);
@@ -6409,7 +6436,7 @@ function formatHooksJSON(hooks: HookConfigView[], eventOrder: string[]): string
return JSON.stringify({ hooks: ordered }, null, 2);
}
function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
function parseHooksJSON(raw: string, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
const trimmed = raw.trim();
if (!trimmed) return [];
let parsed: unknown;
@@ -6419,21 +6446,21 @@ function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
throw new Error(String((e as Error)?.message ?? e));
}
if (Array.isArray(parsed)) {
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents))).filter((h) => h.event);
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents, t))).filter((h) => h.event);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("expected an object or array");
throw new Error(t("settings.hooksJsonExpectedObjectArray"));
}
const obj = parsed as Record<string, unknown>;
const hooksValue = obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) ? obj.hooks : obj;
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents);
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents, t);
}
function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigView {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("hook item must be an object");
function parseHookArrayItem(item: unknown, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonItemObject"));
const obj = item as Record<string, unknown>;
const event = stringField(obj, "event") || "PreToolUse";
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(`unknown hook event ${event}`);
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
return {
event,
match: stringField(obj, "match"),
@@ -6444,14 +6471,14 @@ function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigVie
};
}
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[]): HookConfigView[] {
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
const valid = new Set(validEvents);
const out: HookConfigView[] = [];
for (const [event, value] of Object.entries(hooks)) {
if (valid.size > 0 && !valid.has(event)) throw new Error(`unknown hook event ${event}`);
if (valid.size > 0 && !valid.has(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
const items = Array.isArray(value) ? value : [value];
for (const item of items) {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`hook ${event} item must be an object`);
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonEventItemObject", { event }));
const obj = item as Record<string, unknown>;
out.push(normalizeHookConfig({
event,
+11 -6
View File
@@ -22,9 +22,10 @@ function nowRate(u?: WireUsage): string | null {
}
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
// drag the average down); it overtakes only when compaction craters single turns.
// turn — but scoped to the EXECUTOR agent only: the wire session counters come
// from the main agent and exclude subagent/planner/auxiliary requests. It is
// only the pre-first-refresh fallback; the authoritative all-sources number is
// contextAvgRate below, so the "session average" label reports one scope.
function avgRate(u?: WireUsage): string | null {
if (!u) return null;
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
@@ -32,8 +33,10 @@ function avgRate(u?: WireUsage): string | null {
}
// contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
// cache tokens (loaded from persisted telemetry on session resume). Used as a
// fallback when no live WireUsage is available yet.
// cache tokens — the tab telemetry that accumulates ALL request sources
// (executor, subagents, planner, auxiliary calls), refreshed at turn
// boundaries. Preferred over avgRate: it matches the 会话费用 tooltip's
// "includes main model, subagents and auxiliary calls" scope.
function contextAvgRate(ctx: ContextInfo): string | null {
const hit = ctx.cacheHitTokens ?? 0;
const miss = ctx.cacheMissTokens ?? 0;
@@ -186,7 +189,9 @@ export function StatusBar({
const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
const nowPct = nowRate(usage);
const avgPct = avgRate(usage) ?? contextAvgRate(context);
// All-sources telemetry first; the executor-only live counters only bridge
// the gap before the first ContextInfo refresh of a fresh session.
const avgPct = contextAvgRate(context) ?? avgRate(usage);
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
const costLabel = formatMoneyLocalized(cost, currency, { locale });
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
+7 -2
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { ChevronRight, Compass } from "lucide-react";
import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { useT } from "../lib/i18n";
@@ -127,7 +127,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
aria-expanded={hasBody ? open : undefined}
>
<span className="tool__label-group">
{hasNested && <span className="tool__nested-count">⊞{nested.length}</span>}
{hasNested && (
<span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
<Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
<span>{nested.length}</span>
</span>
)}
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
+11 -2
View File
@@ -14,7 +14,7 @@ import { isReadOnlyTool } from "../lib/useController";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
type ToolItem = Extract<Item, { kind: "tool" }>;
@@ -315,6 +315,7 @@ export function Transcript({
}, [openAction]);
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
// ── JumpBar integration ───────────────────────────────────────────────────
@@ -391,6 +392,7 @@ export function Transcript({
actionPending={actionPending}
rewindDisabled={rewindDisabled}
hoverMenus={actionHoverMenus}
isLastTurn={turn === lastTurn}
onRewind={(targetTurn, scope) => {
onRewind?.(targetTurn, scope);
setOpenAction(null);
@@ -594,7 +596,7 @@ export function Transcript({
if (!running) pushTurnActions();
}
return out;
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn]);
// ── Assemble rendered output ──────────────────────────────────────────────
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
@@ -632,6 +634,7 @@ export function Transcript({
warmSubcalls={subcallsByParent}
warmUserTurn={userTurn}
warmCheckpoints={checkpointsByTurn}
warmLastTurn={lastTurn}
warmOpenAction={openAction}
warmActionPending={actionPending}
warmRewindDisabled={rewindDisabled}
@@ -687,6 +690,7 @@ const WarmZone = memo(function WarmZone({
warmSubcalls,
warmUserTurn,
warmCheckpoints,
warmLastTurn,
warmOpenAction,
warmActionPending,
warmRewindDisabled,
@@ -709,6 +713,7 @@ const WarmZone = memo(function WarmZone({
warmSubcalls: ReadonlyMap<string, ToolItem[]>;
warmUserTurn: ReadonlyMap<string, number>;
warmCheckpoints: ReadonlyMap<number, CheckpointMeta>;
warmLastTurn?: number;
warmOpenAction: OpenTurnAction | null;
warmActionPending: boolean;
warmRewindDisabled: boolean;
@@ -774,6 +779,7 @@ const WarmZone = memo(function WarmZone({
onEdit={warmOnEdit}
tabId={tabId}
creationMode={creationMode}
lastTurn={warmLastTurn}
/>
</WarmTurnCard>,
);
@@ -821,6 +827,7 @@ function WarmTurnItems({
onEdit,
tabId,
creationMode = false,
lastTurn,
}: {
startIdx: number;
endIdx: number;
@@ -837,6 +844,7 @@ function WarmTurnItems({
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
tabId?: string;
creationMode?: boolean;
lastTurn?: number;
}) {
const nodes: React.ReactNode[] = [];
let actionText = "";
@@ -857,6 +865,7 @@ function WarmTurnItems({
actionPending={actionPending}
rewindDisabled={rewindDisabled}
hoverMenus={actionHoverMenus}
isLastTurn={turn === lastTurn}
onRewind={(targetTurn, scope) => {
onRewind?.(targetTurn, scope);
setOpenAction(null);
+103 -3
View File
@@ -719,12 +719,13 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
return "demo";
}
@@ -819,8 +820,8 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
@@ -872,6 +873,7 @@ function makeMockApp(): AppBindings {
const freshMock = scenario === "fresh";
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
const sandboxEscapeMock = scenario === "sandbox_escape";
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
let pendingAskPreview = false;
@@ -1634,6 +1636,23 @@ function makeMockApp(): AppBindings {
cwd: "~/projects/joyquant-db",
},
];
if (sandboxEscapeMock) {
window.setTimeout(() => {
if (pendingApprovalPreview) return;
pendingApprovalPreview = true;
emitMockTurnStarted();
emit({ kind: "reasoning", text: t("mock.sandboxEscapeReasoning") });
emit({
kind: "approval_request",
approval: {
id: "mock-sandbox-escape-preview",
tool: "sandbox_escape",
subject: t("mock.sandboxEscapeSubject"),
reason: t("mock.sandboxEscapeReason"),
},
});
}, 800);
}
const mockModelCatalog = [
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
@@ -1730,6 +1749,26 @@ function makeMockApp(): AppBindings {
});
return;
}
if (
trimmedInput === "/sandbox-escape-preview" ||
trimmedInput === "sandbox escape preview" ||
trimmedInput === "sandbox_escape preview" ||
trimmedInput === "sandbox escape预览"
) {
pendingApprovalPreview = true;
await delay(250);
if (cancelled) return;
emit({
kind: "approval_request",
approval: {
id: "mock-sandbox-escape-preview",
tool: "sandbox_escape",
subject: t("mock.sandboxEscapeSubject"),
reason: t("mock.sandboxEscapeReason"),
},
});
return;
}
if (
trimmedInput === "/plan-approve-preview" ||
trimmedInput === "plan approve preview" ||
@@ -1844,6 +1883,67 @@ function makeMockApp(): AppBindings {
emitMockTurnDone();
return;
}
if (trimmedInput === "/nested-preview" || trimmedInput === "nested preview" || trimmedInput === "嵌套预览") {
const parentId = "mock-nested-explore";
await delay(180);
if (cancelled) return;
emit({
kind: "reasoning",
text: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
});
emit({
kind: "message",
text: "",
reasoning: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
});
emit({
kind: "tool_dispatch",
tool: {
id: parentId,
name: "explore",
args: JSON.stringify({ task: "在 Reasonix 前端中检查工具调用图标和嵌套调用展示" }),
readOnly: true,
profile: { model: "mock-reasonix", effort: "high" },
},
});
for (let i = 1; i <= 30; i += 1) {
if (cancelled) return;
const id = `mock-nested-${i}`;
const isSearch = i % 3 === 0;
const name = isSearch ? "grep" : "read_file";
const args = isSearch
? { pattern: i % 2 === 0 ? "tool__nested-count" : "explore", path: "desktop/frontend/src" }
: { path: `desktop/frontend/src/${i % 2 === 0 ? "components/ToolCard.tsx" : "styles.css"}`, offset: i * 10, limit: 40 };
emit({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify(args), readOnly: true, parentId } });
emit({
kind: "tool_result",
tool: {
id,
name,
readOnly: true,
output: isSearch ? "3 matches" : "read 40 lines",
durationMs: 24 + i,
},
});
await delay(18);
}
emit({
kind: "tool_result",
tool: {
id: parentId,
name: "explore",
readOnly: true,
output: "已读 20 个文件 · 搜索 10 个文件",
durationMs: 61510,
},
});
emit({
kind: "message",
text: "Mock nested tool preview complete. The explore row now shows the compass count marker.",
});
emitMockTurnDone();
return;
}
// Simulate the server's pre-first-token latency so the deferred user bubble
// and the "un-send on Esc before any reply" path are observable in browser
// dev. Bail if cancelled during the wait — nothing was streamed yet.
@@ -74,6 +74,14 @@ export function questionTurnsById(questions: QuestionAnchor[]): Map<string, numb
return turns;
}
export function lastQuestionTurn(questions: readonly QuestionAnchor[], turns: ReadonlyMap<string, number>): number | undefined {
for (let i = questions.length - 1; i >= 0; i -= 1) {
const turn = turns.get(questions[i].id);
if (turn != null) return turn;
}
return undefined;
}
export function scrollVersion(items: Item[]): string {
return items
.map((it) => {
+236 -24
View File
@@ -10,7 +10,7 @@ import { app, onEvent, onReady } from "./bridge";
import { invalidateCache } from "./composerHistory";
import { formatGuardianAssessmentNotice } from "./guardianEvents";
import { createRafBatch } from "./rafBatch";
import { t } from "./i18n";
import { t, type DictKey } from "./i18n";
import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
import type {
@@ -244,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
const STALE_TURN_RECONCILE_MS = 30_000;
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
const STARTUP_READY_META_RECONCILE_MS = 250;
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
export function shouldReconcileStaleTurn(
state: Pick<State, "running" | "turnActive"> | undefined,
@@ -379,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
const positionalResults = positionalToolResults(messages);
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
const items: Item[] = [];
let items: Item[] = [];
let seq = startSeq;
const consumedToolIDs = new Set<string>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
@@ -394,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
}
if (m.role === "notice") {
if (m.content.trim() !== "") {
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: m.content });
seq++;
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content);
items = next.items;
seq = next.seq;
}
continue;
}
@@ -725,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
}
case "notice":
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: e.text ?? "" }] };
return appendNoticeToState(s, e.level ?? "info", e.text ?? "");
case "phase":
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
case "compaction_started":
@@ -954,24 +957,178 @@ function errorMessage(err: unknown): string {
}
export function effortSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "effort", {
busy: "status.effortSwitchBusy",
leaseHeld: "status.effortSwitchLeaseHeld",
starting: "status.effortSwitchStarting",
startupFailed: "status.effortSwitchStartupFailed",
retry: "status.effortSwitchRetry",
failed: "status.effortSwitchFailed",
});
}
export function modelSwitchNoticeText(err: unknown): string {
const msg = errorMessage(err).trim() || "unknown error";
const unknownModel = /^unknown model (.+)$/i.exec(msg);
if (unknownModel) {
return t("status.modelSwitchUnknown", { model: unknownModel[1] });
}
const unavailable = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
if (unavailable) {
return t("status.modelSwitchProviderUnavailable", { model: unavailable[1], provider: unavailable[2] });
}
return settingSwitchNoticeText(msg, "model", {
busy: "status.modelSwitchBusy",
leaseHeld: "status.modelSwitchLeaseHeld",
starting: "status.modelSwitchStarting",
startupFailed: "status.modelSwitchStartupFailed",
retry: "status.modelSwitchRetry",
failed: "status.modelSwitchFailed",
});
}
export function tokenModeSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "token mode", {
busy: "status.tokenModeSwitchBusy",
leaseHeld: "status.tokenModeSwitchLeaseHeld",
starting: "status.tokenModeSwitchStarting",
startupFailed: "status.tokenModeSwitchStartupFailed",
retry: "status.tokenModeSwitchRetry",
failed: "status.tokenModeSwitchFailed",
});
}
export function localizedBackendNoticeText(text: string): string {
const msg = text.trim();
const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
if (autosave) {
return t("status.sessionAutosaveFailed", { err: autosave[1] });
}
const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
if (saveBefore) {
return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
}
const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
if (modelFallback) {
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
}
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
) {
return t("recovery.noticeSavedCopy");
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
) {
return t("recovery.noticeKeptCurrent");
}
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
return t("recovery.noticeAdoptedCovered");
}
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
return t("recovery.noticeAdopted");
}
return msg;
}
function recoveryNoticeDedupeKey(text: string): string {
const msg = text.trim();
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
msg === t("recovery.noticeSavedCopy")
) {
return "recovery:saved-copy";
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
msg === t("recovery.noticeKeptCurrent")
) {
return "recovery:kept-current";
}
if (
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
msg === t("recovery.noticeAdoptedCovered")
) {
return "recovery:adopted-covered";
}
if (
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
msg === t("recovery.noticeAdopted")
) {
return "recovery:adopted";
}
return "";
}
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string): { items: Item[]; seq: number } {
const text = localizedBackendNoticeText(rawText);
const key = recoveryNoticeDedupeKey(rawText) || recoveryNoticeDedupeKey(text);
if (key && items.some((item) => item.kind === "notice" && recoveryNoticeDedupeKey(item.text) === key)) {
return { items, seq };
}
return { items: [...items, { kind: "notice", id, level, text }], seq: seq + 1 };
}
function appendNoticeToState(s: State, level: "info" | "warn", text: string): State {
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text);
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
}
function localizedSessionAction(action: string): string {
switch (action.trim()) {
case "changing model":
return t("status.actionChangingModel");
case "changing effort":
return t("status.actionChangingEffort");
case "changing token mode":
return t("status.actionChangingTokenMode");
case "rebuilding settings":
return t("status.actionRebuildingSettings");
case "switching sessions":
return t("status.actionSwitchingSessions");
case "switching tabs":
return t("status.actionSwitchingTabs");
case "autosave":
return t("status.actionAutosave");
default:
return action.trim() || t("status.actionCurrentSession");
}
}
function settingSwitchNoticeText(
err: unknown,
setting: "effort" | "model" | "token mode",
keys: {
busy: DictKey;
leaseHeld: DictKey;
starting: DictKey;
startupFailed: DictKey;
retry: DictKey;
failed: DictKey;
},
): string {
const msg = errorMessage(err).trim() || "unknown error";
const lower = msg.toLowerCase();
if (lower.includes("finish or cancel") && lower.includes("before changing effort")) {
return t("status.effortSwitchBusy");
if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
return t(keys.busy);
}
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
return t("status.effortSwitchLeaseHeld");
return t(keys.leaseHeld);
}
if (lower.includes("workspace is still starting")) {
return t("status.effortSwitchStarting");
return t(keys.starting);
}
if (lower.startsWith("workspace failed to start")) {
return t("status.effortSwitchStartupFailed", { err: msg });
return t(keys.startupFailed, { err: msg });
}
if (lower.includes("changed while switching effort") || (lower.includes("tab ") && lower.includes("not found"))) {
return t("status.effortSwitchRetry");
if (lower.includes(`changed while switching ${setting}`) || (lower.includes("tab ") && lower.includes("not found"))) {
return t(keys.retry);
}
return t("status.effortSwitchFailed", { err: msg });
return t(keys.failed, { err: msg });
}
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
@@ -984,6 +1141,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
}
}
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
try {
const meta = await app.MetaForTab(tabId);
dispatchTo(tabId, { type: "meta", meta });
return meta;
} catch {
return undefined;
}
}
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
if (!activeTabId) return;
void replay().catch(() => {});
@@ -1005,6 +1172,8 @@ export function useController() {
const stateRef = useRef(activeState);
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
const readyMetaReconcileSeq = useRef(0);
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
activeTabIdRef.current = activeTabId;
stateRef.current = activeState;
@@ -1391,11 +1560,9 @@ export function useController() {
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
return;
}
if (activeId) {
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
return;
}
void syncActiveTabFromBackend(false, true);
// A ready event can race the initial hydrate. Refresh the tab metadata
// first so a stale ready=false snapshot does not keep the composer locked.
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
});
void syncActiveTabFromBackend(false, true);
@@ -1416,6 +1583,51 @@ export function useController() {
};
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
// If the startup ready event is missed, keep the composer lock in sync with
// the active tab's backend metadata without kicking off tab activation work.
useEffect(() => {
const tabId = activeTabId;
const meta = activeState.meta;
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
readyMetaReconcileSeq.current += 1;
readyMetaReconcileActive.current = undefined;
return;
}
let cancelled = false;
let timer: number | undefined;
const seq = readyMetaReconcileSeq.current + 1;
readyMetaReconcileSeq.current = seq;
readyMetaReconcileActive.current = { tabId, seq };
const stillCurrent = () => {
const active = readyMetaReconcileActive.current;
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
};
const schedule = (attempt: number) => {
timer = window.setTimeout(() => {
void tick(attempt);
}, STARTUP_READY_META_RECONCILE_MS);
};
const tick = async (attempt: number) => {
if (!stillCurrent()) return;
const current = statesRef.current.get(tabId);
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
if (!stillCurrent()) return;
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
schedule(attempt + 1);
};
schedule(0);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
// Stale-turn watchdog: if the frontend thinks the agent is running but the
// turn stream has gone quiet, reconcile with the backend. This catches cases
// where the Wails event channel silently drops turn_done after the final
@@ -1448,7 +1660,7 @@ export function useController() {
}, [activeTabId]);
const sendToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText, originalText?: string) => {
if (!tabId) throw new Error("workspace is still starting");
if (!tabId) throw new Error(t("composer.workspaceStarting"));
const seq = getOrCreateState(statesRef.current, tabId).seq;
const display = displayText.trim();
const submit = submitText.trim();
@@ -1474,7 +1686,7 @@ export function useController() {
return sendToTab(tabId, displayText, submitText);
}
return activeTabFromBackend().then((active) => {
if (!active?.id) throw new Error("workspace is still starting");
if (!active?.id) throw new Error(t("composer.workspaceStarting"));
setActiveTabId(active.id);
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
@@ -1484,7 +1696,7 @@ export function useController() {
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
const runShell = useCallback(async (command: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
dispatchTo(activeTabId, { type: "user", text: `!${command}`, seq: getOrCreateState(statesRef.current, activeTabId).seq });
try {
await app.RunShellForTab(activeTabId, command);
@@ -1495,7 +1707,7 @@ export function useController() {
}, [activeTabId, dispatchTo]);
const steer = useCallback(async (text: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
// No optimistic user bubble: rewind/fork map turns by counting user items,
// and a steer is not a backend turn — the Steer event's ↪ notice is the
// visible confirmation (#3660).
@@ -1733,7 +1945,7 @@ export function useController() {
try {
await app.SetModelForTab(activeTabId, name);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.modelSwitchFailed", { err: errorMessage(err) }) });
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) });
return;
}
try {
@@ -1763,7 +1975,7 @@ export function useController() {
try {
await app.SetTokenModeForTab(activeTabId, mode);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.tokenModeSwitchFailed", { err: errorMessage(err) }) });
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
return;
}
try {
+72 -3
View File
@@ -21,6 +21,7 @@ export const en = {
"common.busyHint": "Finish or stop the current turn first",
"common.loading": "Loading…",
"composer.readOnlyChannel": "Channel conversation is read-only",
"composer.workspaceStarting": "Workspace is still starting",
"app.splashSubtitle": "Agent Workspace",
// top bar
@@ -531,14 +532,37 @@ export const en = {
"modelSwitcher.searchPlaceholder": "Search models…",
"modelSwitcher.noMatches": "No matching models",
"status.noModels": "no switchable models",
"status.modelSwitchBusy": "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.modelSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"status.modelSwitchStarting": "This session is still starting. Try changing models again in a moment.",
"status.modelSwitchStartupFailed": "This session did not start, so the model cannot change: {err}",
"status.modelSwitchRetry": "The current session changed while switching models. Try once more.",
"status.modelSwitchUnknown": "Unknown model {model}.",
"status.modelSwitchProviderUnavailable": "Model {model} is unavailable because provider {provider} is not added.",
"status.modelSwitchFailed": "Model switch failed: {err}",
"status.modelFallbackSwitched": "Model {model} is no longer available; switched to {fallback}.",
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
"status.tokenModeSwitchBusy": "Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing token saver.",
"status.tokenModeSwitchStarting": "This session is still starting. Try changing token saver again in a moment.",
"status.tokenModeSwitchStartupFailed": "This session did not start, so token saver cannot change: {err}",
"status.tokenModeSwitchRetry": "The current session changed while switching token saver. Try once more.",
"status.tokenModeSwitchFailed": "Token saver switch failed: {err}",
"status.sessionAutosaveFailed": "Session autosave failed: {err}",
"status.sessionSaveFailedBefore": "Session save failed before {action}: {err}",
"status.actionChangingModel": "changing models",
"status.actionChangingEffort": "changing reasoning effort",
"status.actionChangingTokenMode": "changing token saver",
"status.actionRebuildingSettings": "refreshing settings",
"status.actionSwitchingSessions": "switching sessions",
"status.actionSwitchingTabs": "switching tabs",
"status.actionAutosave": "autosave",
"status.actionCurrentSession": "saving the current session",
"status.effort": "effort {level}",
"status.effortTitle": "Reasoning effort",
"status.effortAutoTitle": "Reasoning effort: auto (model default: {def})",
@@ -663,6 +687,29 @@ export const en = {
"approval.allowRulePersistent": "Always allow (save)",
"approval.allowEditsSession": "Allow edits for session",
"approval.allowFilePersistent": "Always allow this file (save)",
"approval.allowSandboxEscapeSession": "Use real environment for this session",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "edit file",
"approval.toolLabelWriteFile": "write file",
"approval.toolLabelMultiEdit": "multi edit",
"approval.toolLabelMoveFile": "move file",
"approval.toolLabelWebFetch": "web fetch",
"approval.toolLabelRunSkill": "run skill",
"approval.toolLabelRemember": "remember",
"approval.toolLabelForget": "forget",
"approval.toolLabelSandboxEscape": "bash sandbox escape",
"approval.toolLabelPlanModeReadOnly": "plan-mode read-only command",
"approval.memorySaveUpdate": "Save/update memory",
"approval.memoryBodyLabel": "body",
"approval.memoryArchivePrefix": "Archive memory ",
"approval.planModeMcpTrustSubject": "MCP {target} as read-only for planning and research",
"approval.planModeMcpTrustReason": "This MCP tool reports read-only, but external read-only hints need your confirmation before plan mode can use them. Choose always allow to remember this trust for future planning and read-only research.",
"approval.planModeBashTrustSubject": "Trust {prefix} as a read-only command prefix while planning\nCommand: {command}",
"approval.planModeBashTrustReason": "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
"approval.sandboxEscapeSubjectFallback": "run shell command unconfined once",
"approval.sandboxEscapeSubjectPrefix": "run unconfined once: ",
"approval.sandboxEscapeWrapReason": "Windows sandbox could not wrap this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
"approval.sandboxEscapeRuntimeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
// ask card
"ask.title": "Need your decision",
@@ -732,6 +779,7 @@ export const en = {
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
"history.failedOpenSession": "Cannot open session",
"history.failedOpenGlobal": "Cannot open global session",
"history.missingWorkspaceRoot": "Cannot open session because its workspace path is missing.",
"history.selectSession": "Select a session to preview.",
"history.trashSessionActions": "Trash session actions",
"history.historySessionActions": "History session actions",
@@ -745,9 +793,10 @@ export const en = {
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
"recovery.reason": "Reason: {reason}",
"recovery.digest": "Digest: {digest}",
"recovery.parent": "Parent: {parent}",
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
// project tree
"projectTree.workspaceTitle": "Projects",
@@ -1542,6 +1591,15 @@ export const en = {
"settings.modelUsage": "Model usage",
"settings.modelUnavailable": "{ref} is not available. Pick a configured model or add provider access.",
"settings.modelNeedsKey": "{provider} needs an API key before this model can run.",
"settings.errorUnknown": "Operation failed.",
"settings.errorUnknownModel": "Unknown model {model}.",
"settings.errorModelProviderMissing": "Model {model} is unavailable because provider {provider} is not added.",
"settings.errorModelProviderNoKey": "Model {model} is unavailable because provider {provider} has no API key.",
"settings.errorRemoveAccessBusy": "Stop active work using {provider} before removing provider access.",
"settings.errorDeleteProviderBusy": "Stop active work using {provider} before deleting the provider.",
"settings.errorSaveBeforeRemoveAccess": "Could not save the current session before removing provider access: {err}",
"settings.errorSaveBeforeDeleteProvider": "Could not save the current session before deleting the provider: {err}",
"settings.errorRemoveProviderNoFallback": "Provider {provider} is used by open tabs, and no other configured provider is available.",
"settings.customProviders": "Custom providers",
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
@@ -1654,6 +1712,8 @@ export const en = {
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
"settings.providerExtraBodyNull": "{path} cannot contain null.",
"settings.providerExtraBodyObjectRequired": "Extra body must be a JSON object.",
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
"settings.fetchingModels": "Refreshing…",
@@ -1736,6 +1796,10 @@ export const en = {
"settings.hooksJsonApplied": "JSON validated. Save to persist it.",
"settings.hooksJsonFormatted": "JSON formatted. Save to persist it.",
"settings.hooksJsonInvalid": "Invalid JSON: {error}",
"settings.hooksJsonExpectedObjectArray": "Expected an object or array.",
"settings.hooksJsonItemObject": "Hook item must be an object.",
"settings.hooksJsonUnknownEvent": "Unknown hook event {event}.",
"settings.hooksJsonEventItemObject": "Hook {event} item must be an object.",
"settings.hooksJsonClipboardEmpty": "Clipboard is empty",
"settings.hooksJsonClipboardUnavailable": "Clipboard is unavailable; copy the textarea manually.",
"settings.hooksJsonPasteFailed": "Paste failed: {error}",
@@ -1825,6 +1889,7 @@ export const en = {
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
"rewind.disabledNoCode": "No code changes were captured after this message",
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
"rewind.disabledNoLater": "There is nothing after this message to summarize",
"rewind.filesChanged": "{count} files",
"rewind.turnFiles": "{count} this turn",
"rewind.both": "Rewind code and conversation",
@@ -1845,6 +1910,7 @@ export const en = {
"rewind.busyConversation": "Rewinding conversation…",
"rewind.busyCode": "Rewinding code…",
"rewind.busyBoth": "Rewinding code and conversation…",
"rewind.failed": "Rewind failed",
"undoRewind.turns": "Rolled back {n} turn(s)",
"undoRewind.filesRestored": "{n} file(s) restored",
"undoRewind.filesRemoved": "{n} file(s) removed",
@@ -2027,6 +2093,9 @@ export const en = {
"mock.topicAi": "AI engineering best practices",
"mock.topicLab": "Exploratory experiments",
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
"mock.sandboxEscapeReasoning": "Windows sandbox helper failed; requesting a one-time unconfined rerun approval.",
"mock.sandboxEscapeSubject": "run unconfined once: go test ./...",
"mock.sandboxEscapeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
"mock.askQ1Header": "Approach",
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
"mock.askQ1Opt1Label": "git stash then pull",
+72 -3
View File
@@ -22,6 +22,7 @@ export const zhTW: Record<DictKey, string> = {
"common.busyHint": "請先完成或停止當前回合",
"common.loading": "載入中…",
"composer.readOnlyChannel": "渠道會話唯讀",
"composer.workspaceStarting": "工作區還在啟動",
"app.splashSubtitle": "Agent Workspace",
// 頂欄
@@ -385,13 +386,31 @@ export const zhTW: Record<DictKey, string> = {
"modelSwitcher.searchPlaceholder": "搜尋模型…",
"modelSwitcher.noMatches": "沒有匹配的模型",
"status.noModels": "沒有可切換模型",
"status.modelSwitchBusy": "暫時不能切換模型。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.modelSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換模型。",
"status.modelSwitchStarting": "會話還在啟動,稍後再切換模型。",
"status.modelSwitchStartupFailed": "目前會話啟動失敗,無法切換模型:{err}",
"status.modelSwitchRetry": "目前會話已變更,請再試一次切換模型。",
"status.modelSwitchUnknown": "未知模型 {model}。",
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
"status.modelSwitchFailed": "模型切換失敗:{err}",
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切換到 {fallback}。",
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
"status.sessionAutosaveFailed": "會話自動儲存失敗:{err}",
"status.sessionSaveFailedBefore": "在{action}前儲存會話失敗:{err}",
"status.actionChangingModel": "切換模型",
"status.actionChangingEffort": "切換推理力度",
"status.actionChangingTokenMode": "切換省 token 模式",
"status.actionRebuildingSettings": "重新整理設定",
"status.actionSwitchingSessions": "切換會話",
"status.actionSwitchingTabs": "切換標籤頁",
"status.actionAutosave": "自動儲存",
"status.actionCurrentSession": "儲存目前會話",
"status.effort": "effort {level}",
"status.effortTitle": "推理力度",
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
@@ -515,6 +534,29 @@ export const zhTW: Record<DictKey, string> = {
"approval.allowRulePersistent": "總是允許(儲存)",
"approval.allowEditsSession": "本會話允許編輯",
"approval.allowFilePersistent": "總是允許此檔案(儲存)",
"approval.allowSandboxEscapeSession": "本會話使用真實環境",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "編輯檔案",
"approval.toolLabelWriteFile": "寫入檔案",
"approval.toolLabelMultiEdit": "批次編輯",
"approval.toolLabelMoveFile": "移動檔案",
"approval.toolLabelWebFetch": "讀取網頁",
"approval.toolLabelRunSkill": "執行技能",
"approval.toolLabelRemember": "儲存記憶",
"approval.toolLabelForget": "刪除記憶",
"approval.toolLabelSandboxEscape": "bash 沙箱降級核准",
"approval.toolLabelPlanModeReadOnly": "計劃模式唯讀命令",
"approval.memorySaveUpdate": "儲存/更新記憶",
"approval.memoryBodyLabel": "正文",
"approval.memoryArchivePrefix": "封存記憶 ",
"approval.planModeMcpTrustSubject": "將 MCP {target} 作為計劃/研究唯讀工具信任",
"approval.planModeMcpTrustReason": "這個 MCP 工具宣告自己是唯讀的,但外部唯讀提示需要你確認後,計劃模式才能使用。選擇總是允許可為後續計劃和唯讀研究記住這份信任。",
"approval.planModeBashTrustSubject": "在計劃模式中信任 {prefix} 為唯讀命令前綴\n命令:{command}",
"approval.planModeBashTrustReason": "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
"approval.sandboxEscapeSubjectFallback": "僅本次不進沙箱執行 shell 命令",
"approval.sandboxEscapeSubjectPrefix": "僅本次不進沙箱執行:",
"approval.sandboxEscapeWrapReason": "Windows 沙箱無法包裝這條命令。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
// 提問卡片
"ask.title": "需要你決定",
@@ -575,6 +617,7 @@ export const zhTW: Record<DictKey, string> = {
"history.failedOpenProject": "無法開啟會話,{name}不存在:{path}",
"history.failedOpenSession": "無法開啟會話",
"history.failedOpenGlobal": "無法開啟全域會話",
"history.missingWorkspaceRoot": "無法開啟會話:缺少工作區路徑。",
"history.selectSession": "選擇一個會話以預覽。",
"history.trashSessionActions": "回收站會話操作",
"history.historySessionActions": "歷史會話操作",
@@ -588,9 +631,10 @@ export const zhTW: Record<DictKey, string> = {
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父會話:{parent}",
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
// 專案樹
"projectTree.workspaceTitle": "專案",
@@ -950,6 +994,15 @@ export const zhTW: Record<DictKey, string> = {
"settings.modelUsage": "使用模型",
"settings.modelUnavailable": "{ref} 不可用。請選擇已設定模型,或先新增模型服務接入。",
"settings.modelNeedsKey": "{provider} 需要設定 API key 後才能執行該模型。",
"settings.errorUnknown": "操作失敗。",
"settings.errorUnknownModel": "未知模型 {model}。",
"settings.errorModelProviderMissing": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因為提供商 {provider} 尚未設定 API Key。",
"settings.errorRemoveAccessBusy": "請先停止正在使用 {provider} 的任務,再移除模型服務接入。",
"settings.errorDeleteProviderBusy": "請先停止正在使用 {provider} 的任務,再刪除模型服務。",
"settings.errorSaveBeforeRemoveAccess": "移除模型服務接入前儲存目前會話失敗:{err}",
"settings.errorSaveBeforeDeleteProvider": "刪除模型服務前儲存目前會話失敗:{err}",
"settings.errorRemoveProviderNoFallback": "模型服務 {provider} 正被開啟的標籤頁使用,且沒有其他可用模型服務可切換。",
"settings.customProviders": "自訂供應商",
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
@@ -1062,6 +1115,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
"settings.providerExtraBodyObjectRequired": "額外請求體必須是 JSON 物件。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
"settings.fetchingModels": "重新整理中…",
@@ -1170,6 +1225,7 @@ export const zhTW: Record<DictKey, string> = {
"rewind.disabledNoBoundary": "這條訊息的會話邊界不可用",
"rewind.disabledNoCode": "這條訊息之後沒有捕獲到程式碼變更",
"rewind.disabledNoEarlier": "這條訊息之前沒有可總結內容",
"rewind.disabledNoLater": "這條訊息之後沒有可總結內容",
"rewind.filesChanged": "{count} 個檔案",
"rewind.turnFiles": "本輪 {count} 個",
"rewind.both": "回滾程式碼和對話",
@@ -1190,6 +1246,7 @@ export const zhTW: Record<DictKey, string> = {
"rewind.busyConversation": "正在回滾對話…",
"rewind.busyCode": "正在回滾程式碼…",
"rewind.busyBoth": "正在回滾程式碼和對話…",
"rewind.failed": "回滾失敗",
"undoRewind.turns": "已回滾 {n} 輪對話",
"undoRewind.filesRestored": "還原了 {n} 個檔案",
"undoRewind.filesRemoved": "移除了 {n} 個檔案",
@@ -1316,6 +1373,9 @@ export const zhTW: Record<DictKey, string> = {
"mock.topicAi": "AI 工程化最佳實踐",
"mock.topicLab": "臨時探索與實驗",
"mock.approvalSubject": "npm run build\n\n需要執行建構命令來驗證前端產物和樣式打包是否正常。",
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失敗,需要確認是否僅本次不進沙箱重跑。",
"mock.sandboxEscapeSubject": "僅本次不進沙箱執行:go test ./...",
"mock.sandboxEscapeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
"mock.askQ1Header": "處理方向",
"mock.askQ1Prompt": "git pull 的衝突你想怎麼處理?",
"mock.askQ1Opt1Label": "git stash 後 pull",
@@ -1487,6 +1547,11 @@ export const zhTW: Record<DictKey, string> = {
"status.costLabel": "會話費用",
"status.balanceLabel": "餘額",
"status.jobsLabel": "任務",
"status.tokenModeSwitchBusy": "暫時不能切換省 token 模式。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.tokenModeSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換省 token 模式。",
"status.tokenModeSwitchStarting": "會話還在啟動,稍後再切換省 token 模式。",
"status.tokenModeSwitchStartupFailed": "目前會話啟動失敗,無法切換省 token 模式:{err}",
"status.tokenModeSwitchRetry": "目前會話已變更,請再試一次切換省 token 模式。",
"status.tokenModeSwitchFailed": "省 token 模式切換失敗:{err}",
"status.modelTitle": "當前模型",
"clearContext.title": "清空當前上下文",
@@ -1966,6 +2031,10 @@ export const zhTW: Record<DictKey, string> = {
"settings.hooksJsonApplied": "JSON 已校驗,儲存後生效。",
"settings.hooksJsonFormatted": "JSON 已格式化,儲存後生效。",
"settings.hooksJsonInvalid": "JSON 無效:{error}",
"settings.hooksJsonExpectedObjectArray": "應為物件或陣列。",
"settings.hooksJsonItemObject": "Hook 項必須是物件。",
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
"settings.hooksJsonEventItemObject": "Hook {event} 的項目必須是物件。",
"settings.hooksJsonClipboardEmpty": "剪貼簿為空",
"settings.hooksJsonClipboardUnavailable": "無法存取剪貼簿;可手動複製文字框內容。",
"settings.hooksJsonPasteFailed": "貼上失敗:{error}",
+72 -3
View File
@@ -22,6 +22,7 @@ export const zh: Record<DictKey, string> = {
"common.busyHint": "请先完成或停止当前回合",
"common.loading": "加载中…",
"composer.readOnlyChannel": "渠道会话只读",
"composer.workspaceStarting": "工作区还在启动",
"app.splashSubtitle": "Agent Workspace",
// 顶栏
@@ -532,14 +533,37 @@ export const zh: Record<DictKey, string> = {
"modelSwitcher.searchPlaceholder": "搜索模型…",
"modelSwitcher.noMatches": "没有匹配的模型",
"status.noModels": "没有可切换模型",
"status.modelSwitchBusy": "暂时不能切换模型。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.modelSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换模型。",
"status.modelSwitchStarting": "会话还在启动,稍后再切换模型。",
"status.modelSwitchStartupFailed": "当前会话启动失败,无法切换模型:{err}",
"status.modelSwitchRetry": "当前会话已变化,请再试一次切换模型。",
"status.modelSwitchUnknown": "未知模型 {model}。",
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
"status.modelSwitchFailed": "模型切换失败:{err}",
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切换到 {fallback}。",
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
"status.effortSwitchFailed": "推理力度切换失败:{err}",
"status.tokenModeSwitchBusy": "暂时不能切换省 token 模式。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.tokenModeSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换省 token 模式。",
"status.tokenModeSwitchStarting": "会话还在启动,稍后再切换省 token 模式。",
"status.tokenModeSwitchStartupFailed": "当前会话启动失败,无法切换省 token 模式:{err}",
"status.tokenModeSwitchRetry": "当前会话已变化,请再试一次切换省 token 模式。",
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
"status.sessionAutosaveFailed": "会话自动保存失败:{err}",
"status.sessionSaveFailedBefore": "在{action}前保存会话失败:{err}",
"status.actionChangingModel": "切换模型",
"status.actionChangingEffort": "切换推理力度",
"status.actionChangingTokenMode": "切换省 token 模式",
"status.actionRebuildingSettings": "刷新设置",
"status.actionSwitchingSessions": "切换会话",
"status.actionSwitchingTabs": "切换标签页",
"status.actionAutosave": "自动保存",
"status.actionCurrentSession": "保存当前会话",
"status.effort": "effort {level}",
"status.effortTitle": "推理力度",
"status.effortAutoTitle": "推理力度:auto(模型默认:{def})",
@@ -664,6 +688,29 @@ export const zh: Record<DictKey, string> = {
"approval.allowRulePersistent": "总是允许(保存)",
"approval.allowEditsSession": "本会话允许编辑",
"approval.allowFilePersistent": "总是允许此文件(保存)",
"approval.allowSandboxEscapeSession": "本会话使用真实环境",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "编辑文件",
"approval.toolLabelWriteFile": "写入文件",
"approval.toolLabelMultiEdit": "批量编辑",
"approval.toolLabelMoveFile": "移动文件",
"approval.toolLabelWebFetch": "读取网页",
"approval.toolLabelRunSkill": "运行技能",
"approval.toolLabelRemember": "保存记忆",
"approval.toolLabelForget": "删除记忆",
"approval.toolLabelSandboxEscape": "bash 沙箱降级审批",
"approval.toolLabelPlanModeReadOnly": "计划模式只读命令",
"approval.memorySaveUpdate": "保存/更新记忆",
"approval.memoryBodyLabel": "正文",
"approval.memoryArchivePrefix": "归档记忆 ",
"approval.planModeMcpTrustSubject": "将 MCP {target} 作为计划/研究只读工具信任",
"approval.planModeMcpTrustReason": "这个 MCP 工具声明自己是只读的,但外部只读提示需要你确认后,计划模式才能使用。选择总是允许可为后续计划和只读研究记住这份信任。",
"approval.planModeBashTrustSubject": "在计划模式中信任 {prefix} 为只读命令前缀\n命令:{command}",
"approval.planModeBashTrustReason": "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
"approval.sandboxEscapeSubjectFallback": "仅本次不进沙箱运行 shell 命令",
"approval.sandboxEscapeSubjectPrefix": "仅本次不进沙箱运行:",
"approval.sandboxEscapeWrapReason": "Windows 沙箱无法包装这条命令。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
// 提问卡片
"ask.title": "需要你决定",
@@ -733,6 +780,7 @@ export const zh: Record<DictKey, string> = {
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
"history.failedOpenSession": "无法打开会话",
"history.failedOpenGlobal": "无法打开全局会话",
"history.missingWorkspaceRoot": "无法打开会话:缺少工作区路径。",
"history.selectSession": "选择一个会话以预览。",
"history.trashSessionActions": "回收站会话操作",
"history.historySessionActions": "历史会话操作",
@@ -746,9 +794,10 @@ export const zh: Record<DictKey, string> = {
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父会话:{parent}",
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
// 项目树
"projectTree.workspaceTitle": "项目",
@@ -1544,6 +1593,15 @@ export const zh: Record<DictKey, string> = {
"settings.modelUsage": "使用模型",
"settings.modelUnavailable": "{ref} 不可用。请选择已配置模型,或先添加模型服务接入。",
"settings.modelNeedsKey": "{provider} 需要配置 API key 后才能运行该模型。",
"settings.errorUnknown": "操作失败。",
"settings.errorUnknownModel": "未知模型 {model}。",
"settings.errorModelProviderMissing": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因为提供商 {provider} 尚未配置 API Key。",
"settings.errorRemoveAccessBusy": "请先停止正在使用 {provider} 的任务,再移除模型服务接入。",
"settings.errorDeleteProviderBusy": "请先停止正在使用 {provider} 的任务,再删除模型服务。",
"settings.errorSaveBeforeRemoveAccess": "移除模型服务接入前保存当前会话失败:{err}",
"settings.errorSaveBeforeDeleteProvider": "删除模型服务前保存当前会话失败:{err}",
"settings.errorRemoveProviderNoFallback": "模型服务 {provider} 正被打开的标签页使用,且没有其他可用模型服务可切换。",
"settings.customProviders": "自定义供应商",
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
@@ -1656,6 +1714,8 @@ export const zh: Record<DictKey, string> = {
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
"settings.providerExtraBodyObjectRequired": "额外请求体必须是 JSON 对象。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
"settings.fetchingModels": "刷新中…",
@@ -1738,6 +1798,10 @@ export const zh: Record<DictKey, string> = {
"settings.hooksJsonApplied": "JSON 已校验,保存后生效。",
"settings.hooksJsonFormatted": "JSON 已格式化,保存后生效。",
"settings.hooksJsonInvalid": "JSON 无效:{error}",
"settings.hooksJsonExpectedObjectArray": "应为对象或数组。",
"settings.hooksJsonItemObject": "Hook 项必须是对象。",
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
"settings.hooksJsonEventItemObject": "Hook {event} 的项目必须是对象。",
"settings.hooksJsonClipboardEmpty": "剪贴板为空",
"settings.hooksJsonClipboardUnavailable": "无法访问剪贴板;可手动复制文本框内容。",
"settings.hooksJsonPasteFailed": "粘贴失败:{error}",
@@ -1827,6 +1891,7 @@ export const zh: Record<DictKey, string> = {
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
"rewind.disabledNoLater": "这条消息之后没有可总结内容",
"rewind.filesChanged": "{count} 个文件",
"rewind.turnFiles": "本轮 {count} 个",
"rewind.both": "回滚代码和对话",
@@ -1847,6 +1912,7 @@ export const zh: Record<DictKey, string> = {
"rewind.busyConversation": "正在回滚对话…",
"rewind.busyCode": "正在回滚代码…",
"rewind.busyBoth": "正在回滚代码和对话…",
"rewind.failed": "回滚失败",
"undoRewind.turns": "已回滚 {n} 轮对话",
"undoRewind.filesRestored": "还原了 {n} 个文件",
"undoRewind.filesRemoved": "移除了 {n} 个文件",
@@ -2029,6 +2095,9 @@ export const zh: Record<DictKey, string> = {
"mock.topicAi": "AI 工程化最佳实践",
"mock.topicLab": "临时探索与实验",
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失败,需要确认是否仅本次不进沙箱重跑。",
"mock.sandboxEscapeSubject": "仅本次不进沙箱运行:go test ./...",
"mock.sandboxEscapeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
"mock.askQ1Header": "处理方向",
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
"mock.askQ1Opt1Label": "git stash 后 pull",
+12 -1
View File
@@ -5207,11 +5207,22 @@ body > .mermaid-diagram--fullscreen {
min-width: 0;
}
.tool__nested-count {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 34px;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
color: color-mix(in srgb, var(--fg-dim) 86%, var(--fg));
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.tool__nested-icon {
width: 14px;
height: 14px;
color: inherit;
flex: 0 0 auto;
}
.tool__status-icon {
font-size: 13px;
line-height: 1;
+46 -4
View File
@@ -57,6 +57,7 @@ type heartbeatConfig struct {
type HeartbeatEngine struct {
mu sync.Mutex
tasks []HeartbeatTask
cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe)
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
done chan struct{}
running bool
@@ -99,6 +100,28 @@ func (e *HeartbeatEngine) loadTasks() []HeartbeatTask {
return cfg.Tasks
}
// noteConfigModLocked records the config file's current mtime so the tick
// external-edit probe does not re-read a file the engine itself just wrote.
func (e *HeartbeatEngine) noteConfigModLocked() {
if info, err := os.Stat(e.configPath()); err == nil {
e.cfgMod = info.ModTime()
}
}
// adoptExternalEditsLocked re-reads the config when its mtime moved since the
// engine last touched the file: heartbeat-tasks.json is documented as human-
// and AI-editable, so an external edit should be scheduled by the next tick
// rather than waiting for a manual Refresh or an app restart.
func (e *HeartbeatEngine) adoptExternalEditsLocked() {
info, err := os.Stat(e.configPath())
if err != nil || info.ModTime().Equal(e.cfgMod) {
return
}
e.cfgMod = info.ModTime()
e.tasks = e.loadTasks()
e.prunePendingTopicsLocked(e.tasks)
}
// saveTasks writes tasks to disk atomically.
func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error {
if tasks == nil {
@@ -129,6 +152,7 @@ func (e *HeartbeatEngine) Start() {
return
}
e.tasks = e.loadTasks()
e.noteConfigModLocked()
e.running = true
go e.loop()
log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks))
@@ -160,10 +184,12 @@ func (e *HeartbeatEngine) loop() {
}
// tick checks every enabled task and runs those whose interval has elapsed.
// It merges results (topicId, lastRunAt) rather than replacing the full list,
// so concurrent HeartbeatSaveTasks edits are not lost.
// It first adopts any external edit to the config file (human/AI-editable),
// then merges results (topicId, lastRunAt) rather than replacing the full
// list, so concurrent HeartbeatSaveTasks edits are not lost.
func (e *HeartbeatEngine) tick() {
e.mu.Lock()
e.adoptExternalEditsLocked()
tasks := append([]HeartbeatTask(nil), e.tasks...)
e.mu.Unlock()
@@ -374,6 +400,7 @@ func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask {
e.mu.Lock()
defer e.mu.Unlock()
e.tasks = e.loadTasks()
e.noteConfigModLocked()
e.prunePendingTopicsLocked(e.tasks)
out := make([]HeartbeatTask, len(e.tasks))
copy(out, e.tasks)
@@ -386,7 +413,9 @@ func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error {
defer e.mu.Unlock()
e.tasks = tasks
e.prunePendingTopicsLocked(tasks)
return e.saveTasks(tasks)
err := e.saveTasks(tasks)
e.noteConfigModLocked()
return err
}
func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) {
@@ -431,7 +460,18 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
if len(updates) == 0 {
return
}
tasks := append([]HeartbeatTask(nil), e.tasks...)
// Rebase onto the on-disk list before the full-list save: the config file
// is documented as human- and AI-editable, so an external edit may have
// landed after the in-memory snapshot this tick ran from. The engine owns
// only the run-state fields (TopicID, LastRunAt, CreatedAt backfill);
// task definitions added, edited, or deleted externally are adopted from
// disk, so the save below can never silently roll an external edit back.
tasks := e.loadTasks()
if tasks == nil {
// Missing or unreadable file: fall back to the in-memory list so the
// run state still lands (the historical behavior).
tasks = append([]HeartbeatTask(nil), e.tasks...)
}
for i := range tasks {
update, ok := updates[tasks[i].ID]
if !ok {
@@ -448,7 +488,9 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
}
}
e.tasks = tasks
e.prunePendingTopicsLocked(tasks)
_ = e.saveTasks(tasks)
e.noteConfigModLocked()
}
// parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
+71
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
@@ -362,3 +363,73 @@ func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) {
t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta)
}
}
func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) {
isolateDesktopUserDirs(t)
engine := &HeartbeatEngine{
tasks: []HeartbeatTask{
{ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true},
},
}
// An external editor (the documented human/AI flow) rewrote the file after
// the engine's in-memory snapshot: task a was edited and task b was added.
external := []HeartbeatTask{
{ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true},
{ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false},
}
if err := engine.saveTasks(external); err != nil {
t.Fatalf("seed external file: %v", err)
}
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
"a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242},
})
if len(engine.tasks) != 2 {
t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks)
}
got := engine.tasks[0]
if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" {
t.Fatalf("external edit was rolled back by the run-state save: %+v", got)
}
if got.TopicID != "topic-a" || got.LastRunAt != 4242 {
t.Fatalf("run state was not merged onto the disk copy: %+v", got)
}
// The full-list save must have preserved the externally added task on disk.
onDisk := engine.loadTasks()
if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" {
t.Fatalf("externally added task was lost on save: %+v", onDisk)
}
}
func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
isolateDesktopUserDirs(t)
engine := newHeartbeatEngine(nil)
if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil {
t.Fatalf("seed file: %v", err)
}
engine.mu.Lock()
engine.tasks = engine.loadTasks()
engine.noteConfigModLocked()
engine.mu.Unlock()
// External edit lands after the engine last touched the file. Force the
// mtime forward so coarse filesystem timestamps cannot make this flaky.
if err := engine.saveTasks([]HeartbeatTask{
{ID: "a", Title: "A", Interval: "1h", Enabled: false},
{ID: "b", Title: "added externally", Interval: "1h", Enabled: false},
}); err != nil {
t.Fatalf("external edit: %v", err)
}
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(engine.configPath(), future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
engine.tick() // disabled tasks only: adoption runs, nothing executes
tasks := engine.ListTasks()
if len(tasks) != 2 || tasks[1].ID != "b" {
t.Fatalf("tick did not adopt the external edit: %+v", tasks)
}
}
+22
View File
@@ -24,9 +24,21 @@ import (
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
"reasonix/internal/sandbox"
_ "reasonix/internal/tool/builtin"
)
// runWindowsSandboxHelperIfRequested reports whether argv (os.Args-shaped, so
// argv[0] is the program name) asks this process to act as the hidden Windows
// sandbox helper, and runs it when so. Split from main so tests can pin that
// the desktop binary keeps the helper route the sandbox wrapper depends on.
func runWindowsSandboxHelperIfRequested(argv []string) (int, bool) {
if len(argv) > 1 && argv[1] == sandbox.WindowsHelperCommand {
return sandbox.RunWindowsSandboxHelper(argv[2:], os.Stdin, os.Stdout, os.Stderr), true
}
return 0, false
}
// assets embeds the built frontend. `all:` so dotfiles (e.g. the dist .gitkeep
// that keeps this directive compilable before the first `pnpm build`) are
// included. A real run requires `pnpm build` (or `wails build`) to populate dist.
@@ -90,6 +102,16 @@ func linuxWebviewGpuPolicy(pattern string) linux.WebviewGpuPolicy {
}
func main() {
// The Windows bash sandbox relaunches the current executable as a hidden
// helper process. Dispatch it before any Wails or single-instance setup:
// otherwise every sandboxed command starts a second GUI instance that
// forwards to the running app and exits 0 with no output, so bash silently
// returns empty on Windows (#6051, #6067, #6072).
if code, ok := runWindowsSandboxHelperIfRequested(os.Args); ok {
os.Exit(code)
}
sandbox.RegisterHelperDispatch()
app := NewApp()
// Restore saved window size, or fall back to the default.
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"log/slog"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
)
// recoveryGCInterval bounds how often the background sweep repeats after the
// startup run. Recovery branches accumulate slowly (only on a save conflict),
// so a low frequency is plenty and keeps the disk scan off the hot path.
const recoveryGCInterval = 6 * time.Hour
// startRecoveryGC waits for tab restore to complete, runs one sweep, then
// repeats on an interval until the app context is cancelled. The wait is
// load-bearing: restoreOrBuildTabs populates a.tabs asynchronously, and a
// sweep against the pre-restore empty tab map would see every saved tab's
// session as closed — and DeleteSession's tab-list persistence would then
// overwrite desktop-tabs.json with that empty snapshot.
func (a *App) startRecoveryGC() {
a.goSafe("recoveryGC", func() {
select {
case <-a.tabsRestoredSignal():
case <-a.ctx.Done():
return
}
a.sweepReclaimableRecoveryBranches()
ticker := time.NewTicker(recoveryGCInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.sweepReclaimableRecoveryBranches()
}
}
})
}
// sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
// preserve nothing unique (their content is covered by a still-present parent
// session), were never continued on, sat idle past the grace period, and are
// not held by any runtime. Trashing — never hard deletion — keeps every swept
// branch recoverable from the session trash. Returns how many were reclaimed.
func (a *App) sweepReclaimableRecoveryBranches() int {
return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now())
}
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time) int {
reclaimed := 0
for _, dir := range dirs {
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, agent.RecoveryGCGracePeriod)
if err != nil {
slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
continue
}
for _, path := range reclaimable {
// Re-check liveness right before disposal: the scan is a snapshot,
// and the user may have opened the branch since. DeleteSession then
// runs the full removal path (removal guard, runtime unbinding,
// trash move), so even a miss here lands in the recoverable trash.
if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
continue
}
if err := a.DeleteSession(path); err != nil {
slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err)
continue
}
reclaimed++
}
}
if reclaimed > 0 {
slog.Info("desktop: moved redundant recovery branches to the session trash", "count", reclaimed)
a.emitProjectTreeChanged()
}
return reclaimed
}
// recoveryGCDirs returns every session directory the desktop lists sessions
// from: the global desktop and legacy shared dirs plus each saved project's
// session dirs, deduplicated.
func recoveryGCDirs() []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
if dir == "" || seen[dir] {
return
}
seen[dir] = true
dirs = append(dirs, dir)
}
add(desktopSessionDir(globalWorkspaceRoot()))
add(config.SessionDir())
for _, project := range loadProjectsFile().Projects {
if root := normalizeProjectRoot(project.Root); root != "" {
add(desktopSessionDir(root))
add(config.ProjectSessionDir(root))
}
}
return dirs
}
// sessionOpenInAnyTab reports whether any tab's current session is path.
// Lease checks cover live runtimes; this additionally covers tabs that hold a
// session without a lease (read-only channel views).
func (a *App) sessionOpenInAnyTab(path string) bool {
key := sessionRuntimeKey(path)
if key == "" {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
for _, tab := range a.tabs {
if tab == nil {
continue
}
if sessionRuntimeKey(tab.currentSessionPath()) == key {
return true
}
}
return false
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
)
// forkCoveredRecoveryBranch builds the reclaimable shape in dir: a conflict
// fork whose parent went on to contain everything the fork preserved.
func forkCoveredRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string) {
t.Helper()
parentPath = filepath.Join(dir, name+".jsonl")
disk := agent.NewSession("sys")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
if err := disk.Save(parentPath); err != nil {
t.Fatalf("Save parent: %v", err)
}
stale := agent.NewSession("sys")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
info, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
covering := agent.NewSession("")
covering.Messages = append([]provider.Message(nil), stale.Snapshot()...)
covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
if err := covering.Save(parentPath); err != nil {
t.Fatalf("Save covering parent: %v", err)
}
return parentPath, info.Path
}
func TestRecoveryGCTrashesCoveredForkAndKeepsParent(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
parentPath, branchPath := forkCoveredRecoveryBranch(t, dir, "session")
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 1 {
t.Fatalf("reclaimed = %d, want 1", got)
}
if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
t.Fatalf("reclaimed branch still present at %s (err=%v)", branchPath, err)
}
key := filepath.Base(branchPath)
trashPath := filepath.Join(dir, sessionTrashDir, key, key)
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("reclaimed branch should be in trash: %v", err)
}
if _, err := os.Stat(parentPath); err != nil {
t.Fatalf("parent session must be untouched: %v", err)
}
// A second sweep is a no-op: nothing left to reclaim.
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
t.Fatalf("second sweep reclaimed = %d, want 0", got)
}
}
func TestRecoveryGCSkipsBranchOpenInTab(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
_, branchPath := forkCoveredRecoveryBranch(t, dir, "open")
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
t.Fatalf("reclaimed = %d, want 0 while the branch is open in a tab", got)
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("open branch must be untouched: %v", err)
}
}
// TestRecoveryGCFirstSweepWaitsForTabRestore forces the startup race the
// review caught: a saved recovery tab exists in desktop-tabs.json but a.tabs
// has not been populated yet. The GC's first sweep must wait for the restore
// gate — sweeping early would judge the branch "not open in any tab" and
// DeleteSession would persist the pre-restore (empty) tab list over the
// user's saved one.
func TestRecoveryGCFirstSweepWaitsForTabRestore(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
_, branchPath := forkCoveredRecoveryBranch(t, dir, "startup")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app := &App{
ctx: ctx,
tabs: map[string]*WorkspaceTab{},
tabsRestored: make(chan struct{}),
}
swept := make(chan int, 1)
go func() {
select {
case <-app.tabsRestoredSignal():
case <-ctx.Done():
swept <- -1
return
}
swept <- app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
}()
// Gate still closed: the sweep must not have run — the branch is intact.
select {
case n := <-swept:
t.Fatalf("sweep ran before tab restore completed (reclaimed=%d)", n)
case <-time.After(100 * time.Millisecond):
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("branch touched before restore completed: %v", err)
}
// Restore lands the saved tab holding the branch, then opens the gate:
// the sweep runs and must skip the now-open branch.
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
app.mu.Lock()
app.tabs["tab"] = tab
app.mu.Unlock()
app.markTabsRestored()
if n := <-swept; n != 0 {
t.Fatalf("post-restore sweep reclaimed = %d, want 0 (branch is open in a restored tab)", n)
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("restored tab's branch must be untouched: %v", err)
}
// markTabsRestored is idempotent (restore + recover paths may both fire).
app.markTabsRestored()
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"errors"
"log/slog"
"syscall"
)
// Sanitized errors surfaced when a destructive or restore session operation
// hits a real filesystem blocker. Like errSessionBusyElsewhere, they
// intentionally carry no path, so raw OS error text never reaches the UI.
var (
errSessionFileLocked = errors.New("a session file is temporarily locked by another program (often antivirus or sync tools) — wait a moment and retry")
errSessionFileAccessDenied = errors.New("access to a session file was denied — close programs that may be using it or check folder permissions, then retry")
errSessionDiskFull = errors.New("not enough disk space to finish the operation — free some space and retry")
)
// friendlySessionFileError rewrites raw OS-level filesystem errors from the
// session trash/restore/purge flows (e.g. a Windows sharing violation while a
// scanner holds a transcript) into the actionable, path-free errors above.
// The original error is logged so diagnostics keep the path and errno.
// Unrecognized errors — including already-sanitized ones like
// errSessionBusyElsewhere — pass through unchanged.
func friendlySessionFileError(err error) error {
if err == nil {
return nil
}
var errno syscall.Errno
if !errors.As(err, &errno) {
return err
}
var friendly error
switch {
case isFileInUseErrno(errno):
friendly = errSessionFileLocked
case isAccessDeniedErrno(errno):
friendly = errSessionFileAccessDenied
case isDiskFullErrno(errno):
friendly = errSessionDiskFull
default:
return err
}
slog.Warn("desktop: session file operation blocked", "err", err)
return friendly
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"errors"
"os"
"runtime"
"strings"
"syscall"
"testing"
)
// Platform errnos for the blocked-file classes. Windows values follow the
// isRenameCrossDeviceOrBusy convention of naming the raw code inline.
func blockedFileErrnos(t *testing.T) (inUse, accessDenied, diskFull syscall.Errno) {
t.Helper()
if runtime.GOOS == "windows" {
return 32, 5, 112 // ERROR_SHARING_VIOLATION, ERROR_ACCESS_DENIED, ERROR_DISK_FULL
}
return syscall.EBUSY, syscall.EACCES, syscall.ENOSPC
}
func TestFriendlySessionFileErrorMapsBlockedFileErrors(t *testing.T) {
inUse, accessDenied, diskFull := blockedFileErrnos(t)
secret := "/very/secret/session.jsonl"
cases := []struct {
name string
err error
want error
}{
{
name: "rename sharing violation",
err: &os.LinkError{Op: "rename", Old: secret, New: secret + ".trash", Err: inUse},
want: errSessionFileLocked,
},
{
name: "remove access denied",
err: &os.PathError{Op: "remove", Path: secret, Err: accessDenied},
want: errSessionFileAccessDenied,
},
{
name: "write disk full",
err: &os.PathError{Op: "write", Path: secret, Err: diskFull},
want: errSessionDiskFull,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := friendlySessionFileError(tc.err)
if got != tc.want {
t.Fatalf("friendlySessionFileError() = %v, want %v", got, tc.want)
}
if strings.Contains(got.Error(), secret) {
t.Fatalf("sanitized error leaks the path: %v", got)
}
})
}
}
func TestFriendlySessionFileErrorPassesThroughOtherErrors(t *testing.T) {
if err := friendlySessionFileError(nil); err != nil {
t.Fatalf("nil should stay nil, got %v", err)
}
plain := errors.New("plain failure")
if got := friendlySessionFileError(plain); got != plain {
t.Fatalf("plain error rewritten to %v", got)
}
if got := friendlySessionFileError(errSessionBusyElsewhere); got != errSessionBusyElsewhere {
t.Fatalf("sanitized busy error rewritten to %v", got)
}
notExist := &os.PathError{Op: "lstat", Path: "gone.jsonl", Err: syscall.ENOENT}
if got := friendlySessionFileError(notExist); got != error(notExist) {
t.Fatalf("not-exist error rewritten to %v", got)
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
package main
import "syscall"
func isFileInUseErrno(errno syscall.Errno) bool {
return errno == syscall.EBUSY || errno == syscall.ETXTBSY
}
func isAccessDeniedErrno(errno syscall.Errno) bool {
return errno == syscall.EACCES || errno == syscall.EPERM
}
func isDiskFullErrno(errno syscall.Errno) bool {
return errno == syscall.ENOSPC || errno == syscall.EDQUOT
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
package main
import (
"syscall"
"golang.org/x/sys/windows"
)
func isFileInUseErrno(errno syscall.Errno) bool {
return errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_LOCK_VIOLATION
}
func isAccessDeniedErrno(errno syscall.Errno) bool {
return errno == windows.ERROR_ACCESS_DENIED
}
func isDiskFullErrno(errno syscall.Errno) bool {
return errno == windows.ERROR_DISK_FULL || errno == windows.ERROR_HANDLE_DISK_FULL
}
+137
View File
@@ -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)
}
}
+22 -2
View File
@@ -1,6 +1,7 @@
package main
import (
"log/slog"
"strings"
"reasonix/internal/agent"
@@ -16,6 +17,21 @@ func systemPromptFrom(messages []provider.Message) string {
return ""
}
// logSystemPromptSwap leaves a trace whenever a resume/rebind replaces a
// conversation's persisted system prompt with different bytes: that swap
// invalidates the whole conversation's provider prefix cache (misses bill at
// 10x hits) and persists the rewrite. With probe snapshots keeping composition
// deterministic, this should fire only on genuine config changes — if it shows
// up in field logs without one, a new nondeterminism source crept into the
// prompt assembly.
func logSystemPromptSwap(persisted, fresh, path string) {
if persisted == "" || fresh == "" || persisted == fresh {
return
}
slog.Warn("desktop: resume swapped a differing system prompt; conversation prefix cache will miss",
"path", path, "persisted_len", len(persisted), "fresh_len", len(fresh))
}
func withFreshSystemPrompt(messages []provider.Message, system string) []provider.Message {
if strings.TrimSpace(system) == "" {
return messages
@@ -40,9 +56,11 @@ func sessionWithFreshSystemPrompt(session *agent.Session, system string) *agent.
return nil
}
messages := session.Snapshot()
if systemPromptFrom(messages) == "" {
persisted := systemPromptFrom(messages)
if persisted == "" {
return session
}
logSystemPromptSwap(persisted, system, "")
return session.CloneWithMessages(withFreshSystemPrompt(messages, system))
}
@@ -55,7 +73,9 @@ func resumeWithFreshSystemPrompt(ctrl interface {
return
}
if len(messages) > 0 {
next := withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))
fresh := systemPromptFrom(ctrl.History())
logSystemPromptSwap(systemPromptFrom(messages), fresh, path)
next := withFreshSystemPrompt(messages, fresh)
if path != "" {
if loaded, err := agent.LoadSession(path); err == nil && loaded != nil {
if resumed, ok := loaded.CloneWithMessagesIfCompatible(next); ok {
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// capturingProvider records the exact message list of every request it
// receives, marshaled at capture time, so tests can compare request bytes.
type capturingProvider struct {
mu sync.Mutex
requests [][]byte
}
func (p *capturingProvider) Name() string { return "capturing" }
func (p *capturingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
b, err := json.Marshal(req.Messages)
if err != nil {
return nil, err
}
p.mu.Lock()
p.requests = append(p.requests, b)
p.mu.Unlock()
ch := make(chan provider.Chunk, 1)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
close(ch)
return ch, nil
}
func (p *capturingProvider) lastRequestMessages(t *testing.T) []provider.Message {
t.Helper()
p.mu.Lock()
defer p.mu.Unlock()
if len(p.requests) == 0 {
t.Fatal("provider captured no requests")
}
var msgs []provider.Message
if err := json.Unmarshal(p.requests[len(p.requests)-1], &msgs); err != nil {
t.Fatalf("unmarshal captured request: %v", err)
}
return msgs
}
func marshalMessages(t *testing.T, msgs []provider.Message) []byte {
t.Helper()
b, err := json.Marshal(msgs)
if err != nil {
t.Fatalf("marshal messages: %v", err)
}
return b
}
// copySessionFiles clones a saved transcript (checkpoint anchor, event log,
// meta sidecar) to an independent path, so a rebind can load the state saved
// at that moment while the original controller keeps running and autosaving.
func copySessionFiles(t *testing.T, from, to string) {
t.Helper()
copied := false
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
b, err := os.ReadFile(from + suffix)
if err != nil {
continue
}
if err := os.WriteFile(to+suffix, b, 0o644); err != nil {
t.Fatalf("copy session file %s: %v", suffix, err)
}
copied = true
}
if !copied {
t.Fatalf("no session files found at %s", from)
}
}
// TestRebindReproducesRequestBytes is the desktop-level byte-stability guard
// for the provider prefix cache. It builds the strongest comparison available:
// from ONE saved transcript, run the same follow-up turn twice — once on the
// original controller (no rebind, the provider-cache-warm baseline) and once
// after the desktop rebind path (agent.LoadSession + sessionWithFreshSystemPrompt
// + Resume on a freshly built controller, the shape of tabs.go's restore). The
// two requests must be byte-identical END TO END — system prompt, prior user
// AND assistant turns, and the composed follow-up. Any divergence means a
// desktop rebuild cold-starts the conversation's provider cache at 10x miss
// pricing (#2945, #5614).
func TestRebindReproducesRequestBytes(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
const systemPrompt = "SYSPROMPT stable bytes"
prov := &capturingProvider{}
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Runner: exec, Executor: exec, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard})
if err := ctrl.RunTurn(context.Background(), "first question"); err != nil {
t.Fatalf("first turn: %v", err)
}
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
// Freeze the after-turn-one transcript on an independent path: the
// baseline turn below autosaves onto the original path, and the rebind
// must load the state as saved at this moment.
rebindPath := filepath.Join(dir, "rebind.jsonl")
copySessionFiles(t, path, rebindPath)
// Baseline: the follow-up turn on the ORIGINAL controller — the exact
// request an uninterrupted (cache-warm) session would send.
if err := ctrl.RunTurn(context.Background(), "second question"); err != nil {
t.Fatalf("baseline second turn: %v", err)
}
baseline := prov.lastRequestMessages(t)
baselineBytes := marshalMessages(t, baseline)
if len(baseline) < 4 {
t.Fatalf("baseline request has %d messages, want system + first exchange + follow-up", len(baseline))
}
// Rebind from the transcript saved after turn one: a NEW controller
// composes its (identical) system prompt, the persisted transcript is
// loaded, the fresh prompt is swapped in, and the controller resumes —
// then sends the same follow-up.
prov2 := &capturingProvider{}
exec2 := agent.New(prov2, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
ctrl2 := control.New(control.Options{Runner: exec2, Executor: exec2, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: rebindPath, Label: "test", Sink: event.Discard})
loaded, err := agent.LoadSession(rebindPath)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
ctrl2.Resume(sessionWithFreshSystemPrompt(loaded, systemPromptFrom(ctrl2.History())), rebindPath)
if err := ctrl2.RunTurn(context.Background(), "second question"); err != nil {
t.Fatalf("post-rebind second turn: %v", err)
}
rebound := prov2.lastRequestMessages(t)
reboundBytes := marshalMessages(t, rebound)
if string(reboundBytes) != string(baselineBytes) {
t.Fatalf("rebind changed the request bytes — the provider prefix cache is invalidated:\nbaseline: %s\nrebound: %s", baselineBytes, reboundBytes)
}
}
// TestRebindWithDriftedPromptBreaksRequestPrefix pins the failure mode the
// guard above protects against: when the freshly composed prompt differs from
// the one the transcript was recorded with, the swap rewrites the first
// message and the request diverges from the no-rebind baseline. If a future
// change moves the swap policy to keep the persisted prompt for resumed
// conversations, this test should be updated to assert the bytes survive
// instead.
func TestRebindWithDriftedPromptBreaksRequestPrefix(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
prov := &capturingProvider{}
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession("SYSPROMPT v1"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Runner: exec, Executor: exec, SystemPrompt: "SYSPROMPT v1", SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard})
if err := ctrl.RunTurn(context.Background(), "first question"); err != nil {
t.Fatalf("first turn: %v", err)
}
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
rebindPath := filepath.Join(dir, "rebind.jsonl")
copySessionFiles(t, path, rebindPath)
if err := ctrl.RunTurn(context.Background(), "second question"); err != nil {
t.Fatalf("baseline second turn: %v", err)
}
baseline := prov.lastRequestMessages(t)
baselineBytes := marshalMessages(t, baseline)
prov2 := &capturingProvider{}
exec2 := agent.New(prov2, tool.NewRegistry(), agent.NewSession("SYSPROMPT v2 drifted"), agent.Options{}, event.Discard)
ctrl2 := control.New(control.Options{Runner: exec2, Executor: exec2, SystemPrompt: "SYSPROMPT v2 drifted", SessionDir: dir, SessionPath: rebindPath, Label: "test", Sink: event.Discard})
loaded, err := agent.LoadSession(rebindPath)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
ctrl2.Resume(sessionWithFreshSystemPrompt(loaded, systemPromptFrom(ctrl2.History())), rebindPath)
if err := ctrl2.RunTurn(context.Background(), "second question"); err != nil {
t.Fatalf("post-rebind turn: %v", err)
}
rebound := prov2.lastRequestMessages(t)
if string(marshalMessages(t, rebound)) == string(baselineBytes) {
t.Fatal("drifted prompt unexpectedly reproduced the baseline request — the swap policy changed; update these guards")
}
if len(rebound) == 0 || rebound[0].Content == baseline[0].Content {
t.Fatalf("drift should surface in the leading system message; got %q", rebound[0].Content)
}
}
+147 -28
View File
@@ -147,6 +147,7 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
{src: store.SessionConflictLog(sessionPath), name: stem + ".conflicts.jsonl"},
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
@@ -214,7 +215,30 @@ func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
}
defer guard.Release()
itemDir := filepath.Join(sessionTrashPath(dir), key)
if err := os.MkdirAll(itemDir, 0o755); err != nil {
if info, err := os.Stat(itemDir); err == nil {
if !info.IsDir() {
return fmt.Errorf("session trash target is not a directory: %s", key)
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
if err != nil {
return err
}
if !matches {
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
if err != nil {
return err
}
}
} else if err != nil && !os.IsNotExist(err) {
return err
}
} else if os.IsNotExist(err) {
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
} else {
return err
}
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
@@ -259,7 +283,10 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
if removable {
return nil
}
return fmt.Errorf("session already exists in trash: %s", key)
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
return errSessionBusyElsewhere
}
return nil
} else if err != nil && !os.IsNotExist(err) {
return err
}
@@ -270,37 +297,64 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
return nil
}
func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, error) {
type preparedSessionTrashTarget struct {
shouldMove bool
itemDir string
allocateUnique bool
}
func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) {
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
return false, nil
return preparedSessionTrashTarget{}, nil
} else if err != nil {
return false, err
return preparedSessionTrashTarget{}, err
}
itemDir := filepath.Join(sessionTrashPath(dir), key)
if info, err := os.Stat(itemDir); err == nil {
if !info.IsDir() {
return false, fmt.Errorf("session trash target is not a directory: %s", key)
return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key)
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
if err != nil {
return false, err
return preparedSessionTrashTarget{}, err
}
if removable {
return false, removeDesktopSessionArtifacts(sessionPath)
return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath)
}
return false, fmt.Errorf("session already exists in trash: %s", key)
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
return preparedSessionTrashTarget{}, errSessionBusyElsewhere
}
return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil
} else if err != nil && !os.IsNotExist(err) {
return false, err
return preparedSessionTrashTarget{}, err
}
if err := os.RemoveAll(itemDir); err != nil {
return false, err
return preparedSessionTrashTarget{}, err
}
} else if !os.IsNotExist(err) {
return false, err
return preparedSessionTrashTarget{}, err
}
return true, nil
return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil
}
func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) {
root := sessionTrashPath(dir)
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
stem := strings.TrimSuffix(key, ".jsonl")
for i := 0; i < 100; i++ {
name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i)
itemDir := filepath.Join(root, name)
if err := os.Mkdir(itemDir, 0o755); err == nil {
return itemDir, nil
} else if !os.IsExist(err) {
return "", err
}
}
return "", fmt.Errorf("could not allocate unique trash target for session: %s", key)
}
// liveSessionRemovableWithExistingTrash reports whether a live session file may
@@ -383,11 +437,11 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
return err
}
shouldMove, err := prepareSessionTrashTarget(dir, sessionPath, key)
target, err := prepareSessionTrashTarget(dir, sessionPath, key)
if err != nil {
return err
}
if !shouldMove {
if !target.shouldMove {
return nil
}
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
@@ -397,8 +451,13 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
return err
}
defer guard.Release()
itemDir := filepath.Join(sessionTrashPath(dir), key)
if err := os.MkdirAll(itemDir, 0o755); err != nil {
itemDir := target.itemDir
if target.allocateUnique {
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
if err != nil {
return err
}
} else if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
if beforeMove != nil {
@@ -443,17 +502,27 @@ func listTrashedSessionFiles(dir string) ([]string, error) {
if !e.IsDir() {
continue
}
key := e.Name()
if filepath.Ext(key) != ".jsonl" || filepath.Base(key) != key {
continue
itemDir := filepath.Join(root, e.Name())
keys := []string{}
if b, err := os.ReadFile(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil {
var meta trashedSessionMeta
if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) {
keys = append(keys, meta.Key)
}
}
path := filepath.Join(root, key, key)
validPath, _, _, err := validateTrashedSessionPath(dir, path)
if err != nil {
continue
if store.IsSessionTranscriptName(e.Name()) {
keys = append(keys, e.Name())
}
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
paths = append(paths, validPath)
for _, key := range keys {
path := filepath.Join(itemDir, key)
validPath, _, _, err := validateTrashedSessionPath(dir, path)
if err != nil {
continue
}
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
paths = append(paths, validPath)
break
}
}
}
return paths, nil
@@ -544,6 +613,8 @@ func movePathIfExists(src, dst string) error {
// Try os.Rename first — it's atomic and fast when it works.
if err := os.Rename(src, dst); err == nil {
return nil
} else if sourcePathMissing(src) {
return nil
} else if !isRenameCrossDeviceOrBusy(err) {
return err
}
@@ -572,10 +643,28 @@ func isRenameCrossDeviceOrBusy(err error) bool {
return false
}
func sourcePathMissing(src string) bool {
if strings.TrimSpace(src) == "" {
return true
}
_, err := os.Lstat(src)
return os.IsNotExist(err)
}
// copyPathFn is a seam for tests to simulate a source vanishing mid-copy.
var copyPathFn = copyPath
// copyAndRemove recursively copies src to dst, then removes src. Used as a
// fallback when os.Rename fails (cross-device or Windows file-lock races).
func copyAndRemove(src, dst string) error {
if err := copyPath(src, dst); err != nil {
if err := copyPathFn(src, dst); err != nil {
if sourcePathMissing(src) {
// The source vanished mid-copy; drop the partial destination so
// the trash never keeps a truncated artifact that a later restore
// would resurrect as a corrupted transcript.
_ = os.RemoveAll(dst)
return nil
}
return err
}
// On Windows, wait briefly for any file handle release.
@@ -586,6 +675,9 @@ func copyAndRemove(src, dst string) error {
func copyPath(src, dst string) error {
info, err := os.Lstat(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
mode := info.Mode()
@@ -607,6 +699,10 @@ func copyDir(src, dst string, mode os.FileMode) error {
}
entries, err := os.ReadDir(src)
if err != nil {
if os.IsNotExist(err) {
_ = os.RemoveAll(dst)
return nil
}
return err
}
for _, e := range entries {
@@ -623,6 +719,9 @@ func copyFile(src, dst string, mode os.FileMode) error {
// Open source file.
in, err := os.Open(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
// Create destination file.
@@ -648,6 +747,9 @@ func copyFile(src, dst string, mode os.FileMode) error {
func copySymlink(src, dst string) error {
target, err := os.Readlink(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return os.Symlink(target, dst)
@@ -786,9 +888,19 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || parts[0] != parts[1] {
if len(parts) != 2 {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
if parts[0] != parts[1] {
b, err := os.ReadFile(filepath.Join(root, parts[0], sessionTrashMetaFile))
if err != nil {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
var meta trashedSessionMeta
if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
}
if info, err := os.Lstat(absPath); err == nil {
if info.IsDir() {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
@@ -1005,6 +1117,13 @@ func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{})
if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
return true
}
if paths, err := listTrashedSessionFiles(dir); err == nil {
for _, path := range paths {
if filepath.Base(path) == key {
return true
}
}
}
return false
}
+1 -1
View File
@@ -81,7 +81,7 @@ func TestDeleteSessionKeepsDuplicateLiveSessionHeldByOtherRuntime(t *testing.T)
}
err := app.DeleteSession(filepath.Base(path))
if err == nil || !strings.Contains(err.Error(), "session already exists in trash") {
if err == nil || !strings.Contains(err.Error(), errSessionBusyElsewhere.Error()) {
t.Fatalf("DeleteSession err = %v, want refusal while a foreign runtime holds the lease", err)
}
if got, readErr := os.ReadFile(path); readErr != nil || string(got) != string(content) {
+132
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
@@ -169,6 +170,8 @@ func TestDeleteSessionFile(t *testing.T) {
os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
eventIndexPath := store.SessionEventIndex(sessionPath)
os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
conflictLogPath := store.SessionConflictLog(sessionPath)
os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644)
telemetryPath := sessionTelemetryPath(sessionPath)
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
lockPath := store.SessionLockFile(sessionPath)
@@ -204,6 +207,7 @@ func TestDeleteSessionFile(t *testing.T) {
trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
trashConflictLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.conflicts.jsonl")
trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
@@ -224,6 +228,9 @@ func TestDeleteSessionFile(t *testing.T) {
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
t.Error("session event index should be removed from active sessions")
}
if _, err := os.Stat(conflictLogPath); !os.IsNotExist(err) {
t.Error("session conflict log should be removed from active sessions")
}
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
t.Error("session telemetry should be removed from active sessions")
}
@@ -257,6 +264,9 @@ func TestDeleteSessionFile(t *testing.T) {
if _, err := os.Stat(trashEventIndexPath); err != nil {
t.Fatalf("session event index should be in trash: %v", err)
}
if _, err := os.Stat(trashConflictLogPath); err != nil {
t.Fatalf("session conflict log should be in trash: %v", err)
}
if _, err := os.Stat(trashTelemetryPath); err != nil {
t.Fatalf("session telemetry should be in trash: %v", err)
}
@@ -492,6 +502,10 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
t.Fatal(err)
}
conflictLogPath := store.SessionConflictLog(sessionPath)
if err := os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
telemetryPath := sessionTelemetryPath(sessionPath)
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
t.Fatal(err)
@@ -537,6 +551,9 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
if _, err := os.Stat(eventIndexPath); err != nil {
t.Fatalf("session event index should be restored: %v", err)
}
if _, err := os.Stat(conflictLogPath); err != nil {
t.Fatalf("session conflict log should be restored: %v", err)
}
if _, err := os.Stat(telemetryPath); err != nil {
t.Fatalf("session telemetry should be restored: %v", err)
}
@@ -579,6 +596,51 @@ func TestRestoreTrashedSessionFileWithEmptyLiveStub(t *testing.T) {
}
}
func TestRestoreTrashedSessionFileFromUniqueTrashItem(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "restore-unique.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash old: %v", err)
}
fixedTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"new"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new live session: %v", err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash new with fixed trash conflict: %v", err)
}
trashed, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("list trash: %v", err)
}
var uniqueTrashPath string
for _, candidate := range trashed {
if candidate != fixedTrashPath && filepath.Base(candidate) == filepath.Base(sessionPath) {
uniqueTrashPath = candidate
break
}
}
if uniqueTrashPath == "" {
t.Fatalf("unique trash path not listed, got %#v", trashed)
}
if err := restoreTrashedSessionFile(dir, uniqueTrashPath); err != nil {
t.Fatalf("restore unique trash item: %v", err)
}
if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "new") {
t.Fatalf("restored session = %q err=%v, want new content", string(got), err)
}
if _, err := os.Stat(filepath.Dir(uniqueTrashPath)); !os.IsNotExist(err) {
t.Fatalf("unique trash item should be removed after restore, stat err = %v", err)
}
if _, err := os.Stat(fixedTrashPath); err != nil {
t.Fatalf("original fixed trash item should remain: %v", err)
}
}
func TestValidateSessionTrashTargetKeepsDiscardableLiveStub(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "discardable-live.jsonl")
@@ -789,6 +851,7 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
store.SessionGoalState(sessionPath),
store.SessionEventLog(sessionPath),
store.SessionEventIndex(sessionPath),
store.SessionConflictLog(sessionPath),
sessionTelemetryPath(sessionPath),
store.SessionLockFile(sessionPath),
store.SessionLeaseLock(sessionPath),
@@ -826,6 +889,7 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
store.SessionGoalState(sessionPath),
store.SessionEventLog(sessionPath),
store.SessionEventIndex(sessionPath),
store.SessionConflictLog(sessionPath),
sessionTelemetryPath(sessionPath),
store.SessionLockFile(sessionPath),
store.SessionLeaseLock(sessionPath),
@@ -910,6 +974,74 @@ func TestMovePathIfExistsCopyFallback(t *testing.T) {
}
}
func TestCopyFallbackTreatsMissingSourceAsAlreadyMoved(t *testing.T) {
dir := t.TempDir()
if err := copyAndRemove(filepath.Join(dir, "missing.txt"), filepath.Join(dir, "dst.txt")); err != nil {
t.Fatalf("copy missing source: %v", err)
}
dstDir := filepath.Join(dir, "dst-dir")
if err := copyDir(filepath.Join(dir, "missing-dir"), dstDir, 0o755); err != nil {
t.Fatalf("copy missing dir: %v", err)
}
if _, err := os.Stat(dstDir); !os.IsNotExist(err) {
t.Fatalf("copy missing dir should not leave an empty target, stat err = %v", err)
}
}
func TestCopyFallbackRemovesPartialTargetWhenSourceVanishesMidCopy(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
dst := filepath.Join(dir, "dst.txt")
if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
restore := copyPathFn
copyPathFn = func(copySrc, copyDst string) error {
// Simulate the source vanishing mid-copy: a truncated destination has
// already been written when the copy fails.
if err := os.WriteFile(copyDst, []byte("session tra"), 0o644); err != nil {
t.Fatalf("write partial destination: %v", err)
}
if err := os.Remove(copySrc); err != nil {
t.Fatalf("remove source: %v", err)
}
return errors.New("simulated read failure")
}
t.Cleanup(func() { copyPathFn = restore })
if err := copyAndRemove(src, dst); err != nil {
t.Fatalf("copyAndRemove should treat vanished source as already moved: %v", err)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Fatalf("partial destination should be removed, stat err = %v", err)
}
}
func TestCopyFallbackKeepsErrorWhenSourceStillExists(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
dst := filepath.Join(dir, "dst.txt")
if err := os.WriteFile(src, []byte("session transcript"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
restore := copyPathFn
simulated := errors.New("simulated copy failure")
copyPathFn = func(copySrc, copyDst string) error {
return simulated
}
t.Cleanup(func() { copyPathFn = restore })
if err := copyAndRemove(src, dst); !errors.Is(err, simulated) {
t.Fatalf("copyAndRemove error = %v, want simulated copy failure", err)
}
if _, err := os.Stat(src); err != nil {
t.Fatalf("source should be untouched on real copy failure: %v", err)
}
}
func TestCopyAndRemoveDirectory(t *testing.T) {
dir := t.TempDir()
srcDir := filepath.Join(dir, "src")
+22 -9
View File
@@ -795,7 +795,7 @@ func (a *App) Settings() SettingsView {
Ask: []string{},
Deny: []string{},
},
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto"},
Sandbox: SandboxView{Bash: config.Default().BashMode(), AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto"},
Agent: AgentView{PlannerMaxSteps: 0, MaxSubagentDepth: agent.DefaultMaxSubagentDepth, ColdResumePrune: true, ReasoningLanguage: "auto"},
Bot: botSettingsView(config.BotConfig{}),
AutoPlan: "off",
@@ -815,10 +815,7 @@ func (a *App) Settings() SettingsView {
}
}
ctrl := a.activeCtrl()
bash := cfg.Sandbox.Bash
if bash == "" {
bash = "enforce"
}
bash := cfg.BashMode()
shell := cfg.Tools.Shell.Prefer
if shell == "" {
shell = "auto"
@@ -1538,6 +1535,7 @@ func (a *App) rebuildSettingLocked(setting string) error {
if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
return err
}
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
carried = oldCtrl.History()
}
snap := a.tabRuntimeSnapshot(tab)
@@ -1565,10 +1563,14 @@ func (a *App) rebuildSettingLocked(setting string) error {
})
if err != nil {
if oldCtrl == nil {
leaseHeld := false
a.mu.Lock()
tab.StartupErr = err.Error()
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
a.mu.Unlock()
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(a.ctx)
}
return err
@@ -1603,8 +1605,11 @@ func (a *App) rebuildSettingLocked(setting string) error {
tab.Ctrl = ctrl
tab.model = model
tab.Label = ctrl.Label()
tab.StartupErr = ""
clearTabStartupError(tab)
tab.Ready = true
// Supersede any in-flight startup build: it would otherwise finish later,
// pass its generation check, and overwrite the controller just installed.
a.supersedeTabBuildLocked(tab)
a.saveTabsLocked()
a.mu.Unlock()
a.clearDeferredRebuild(tab.ID)
@@ -2372,9 +2377,13 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
continue
}
tab.Ctrl = nil
// Supersede any in-flight startup build: it was planned against the
// removed provider and would otherwise finish later, pass its
// generation check, and reinstall a controller for it.
a.supersedeTabBuildLocked(tab)
tab.model = fallbackRef
tab.Label = fallbackRef
tab.StartupErr = ""
clearTabStartupError(tab)
tab.Ready = a.ctx == nil
if a.ctx != nil {
rebuildTabs = append(rebuildTabs, tab)
@@ -2487,9 +2496,13 @@ func (a *App) deleteProviderAndRetargetTabs(name string) error {
continue
}
tab.Ctrl = nil
// Supersede any in-flight startup build: it was planned against the
// removed provider and would otherwise finish later, pass its
// generation check, and reinstall a controller for it.
a.supersedeTabBuildLocked(tab)
tab.model = fallbackRef
tab.Label = fallbackRef
tab.StartupErr = ""
clearTabStartupError(tab)
tab.Ready = a.ctx == nil
if a.ctx != nil {
rebuildTabs = append(rebuildTabs, tab)
+1 -1
View File
@@ -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")
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/tool"
)
// TestRestoredTabGoalFollowsSessionGoalState pins the restart half of the
// goal-reset contract: a typed /new or /clear rotates the session through the
// controller (never touching App.NewSession/ClearSession), so the goal saved
// in desktop-tabs.json can be stale. Startup restore must validate it against
// the session's goal-state sidecar — which the rotation wrote as stopped on
// the fresh path — instead of re-seeding the cleared goal via SetGoal.
func TestRestoredTabGoalFollowsSessionGoalState(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
oldPath := filepath.Join(dir, "session.jsonl")
exec := agent.New(stubProvider{}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test", Sink: event.Discard})
ctrl.SetGoal("finish the migration")
// Typed /new: controller-side rotation only.
if err := ctrl.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
newPath := ctrl.SessionPath()
ctrl.Close()
// desktop-tabs.json still carries the pre-rotation goal with the rotated
// path (the tab profile was never told about the /new). Restore must not
// resurrect it: the rotated session's sidecar says stopped.
if got := runningTabSessionGoal(newPath, "finish the migration"); got != "" {
t.Fatalf("restored goal for rotated session = %q, want empty", got)
}
// The OLD session's sidecar still says running: restoring a tab that
// points at the old session keeps its goal (resume semantics).
if got := runningTabSessionGoal(oldPath, "finish the migration"); got != "finish the migration" {
t.Fatalf("restored goal for old session = %q, want preserved", got)
}
// A session without a goal-state sidecar keeps the persisted goal
// (legacy sessions predating the sidecar).
bare := filepath.Join(dir, "bare.jsonl")
if err := os.WriteFile(bare, []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write bare session: %v", err)
}
if got := runningTabSessionGoal(bare, "legacy goal"); got != "legacy goal" {
t.Fatalf("restored goal without sidecar = %q, want fallback preserved", got)
}
}
+599 -121
View File
File diff suppressed because it is too large. Load diff
+157 -2
View File
@@ -32,6 +32,18 @@ func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
}
func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
emit := func(context.Context, string, ...interface{}) {}
if app != nil {
app.runtimeEvents.emit = emit
}
for _, sink := range sinks {
if sink != nil {
sink.runtimeEvents.emit = emit
}
}
}
func tabIDs(tabs []TabMeta) []string {
ids := make([]string, 0, len(tabs))
for _, tab := range tabs {
@@ -909,8 +921,151 @@ func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
if !tab.Ready {
t.Fatal("tab should be ready with startup error")
}
if !strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) {
t.Fatalf("startup error = %q, want lease-held error", tab.StartupErr)
// The surfaced startup error is the sanitized busy message: the raw lease
// error would leak the session path and the holder's host-pid-writer id
// into the topbar banner.
if !strings.Contains(tab.StartupErr, "already open in another Reasonix window") {
t.Fatalf("startup error = %q, want user-facing busy message", tab.StartupErr)
}
if strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) ||
strings.Contains(tab.StartupErr, path) {
t.Fatalf("startup error leaked raw lease details: %q", tab.StartupErr)
}
}
func TestDeferredStartupRetryBuildsAfterLeaseRelease(t *testing.T) {
isolateDesktopUserDirs(t)
prevInterval := deferredRebuildRetryInterval
deferredRebuildRetryInterval = 20 * time.Millisecond
t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "startup-retry.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
released := false
t.Cleanup(func() {
if !released {
lease.Release()
}
})
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
app.enableDeferredRebuildRetry()
t.Cleanup(app.stopDeferredRebuildRetry)
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_retry")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
installNoopRuntimeEvents(app, tab.sink)
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if ctrl := app.controllerForTab(tab); ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
app.buildTabController(tab)
if tab.Ctrl != nil {
t.Fatalf("controller = %T, want nil while external lease is held", tab.Ctrl)
}
if !tab.StartupErrLeaseHeld {
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
}
if !app.deferredRebuildPending(tab.ID) {
t.Fatal("startup retry was not scheduled while the lease was held")
}
lease.Release()
released = true
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if app.deferredRebuildPending(tab.ID) {
t.Fatal("startup retry is still pending after the lease was released")
}
if ctrl := app.controllerForTab(tab); ctrl == nil {
t.Fatal("controller was not rebuilt after the lease was released")
}
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
t.Fatalf("startup error after retry = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
}
}
func TestTabAndCtrlByIDRecoversStartupLeaseBeforeAction(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "startup-before-action.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
released := false
t.Cleanup(func() {
if !released {
lease.Release()
}
})
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_action")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
installNoopRuntimeEvents(app, tab.sink)
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if ctrl := app.controllerForTab(tab); ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
app.buildTabController(tab)
if !tab.StartupErrLeaseHeld {
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
}
lease.Release()
released = true
gotTab, ctrl := app.tabAndCtrlByID(tab.ID)
if gotTab != tab {
t.Fatalf("tabAndCtrlByID tab = %p, want %p", gotTab, tab)
}
if ctrl == nil {
t.Fatal("tabAndCtrlByID did not rebuild the controller before returning")
}
if app.deferredRebuildPending(tab.ID) {
t.Fatal("startup retry remained pending after synchronous recovery")
}
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
t.Fatalf("startup error after synchronous recovery = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
}
}
+198
View File
@@ -199,3 +199,201 @@ func TestContextPanelUsesLastUsageBreakdownWithTelemetryTotal(t *testing.T) {
panel.CacheHitTokens, panel.CacheMissTokens)
}
}
func costedUsageEvent() event.Event {
return event.Event{
Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140},
Pricing: &provider.Pricing{CacheHit: 1, Input: 2, Output: 3, Currency: "¥"},
}
}
func TestSyncTelemetryToSessionReKeysAcrossRotation(t *testing.T) {
dir := t.TempDir()
pathA := filepath.Join(dir, "a.jsonl")
pathB := filepath.Join(dir, "b.jsonl")
tab := &WorkspaceTab{}
tab.syncTelemetryToSession(pathA)
tab.recordUsage(costedUsageEvent())
costA := tab.telemetrySnapshot().Usage.SessionCost
if costA <= 0 {
t.Fatalf("seed cost = %f, want positive", costA)
}
if err := saveTelemetry(pathA+".telemetry.json", tab.telemetrySnapshot()); err != nil {
t.Fatalf("save telemetry A: %v", err)
}
// Same session: in-memory totals survive.
tab.syncTelemetryToSession(pathA)
if got := tab.telemetrySnapshot().Usage.SessionCost; got != costA {
t.Fatalf("same-session sync cost = %f, want %f", got, costA)
}
// Rotation to a session without a sidecar starts from zero — the previous
// session's totals must not bleed over (#5850).
tab.syncTelemetryToSession(pathB)
if got := tab.telemetrySnapshot().Usage; got.SessionCost != 0 || got.TotalTokens != 0 || got.RequestCount != 0 {
t.Fatalf("rotated telemetry = %+v, want zeroed", got)
}
// Rotating back restores session A's persisted totals.
tab.syncTelemetryToSession(pathA)
if got := tab.telemetrySnapshot().Usage.SessionCost; got != costA {
t.Fatalf("restored cost = %f, want %f", got, costA)
}
}
func TestContextUsageForTabReKeysAfterControllerRotation(t *testing.T) {
dir := t.TempDir()
rotated := filepath.Join(dir, "rotated.jsonl")
stale := filepath.Join(dir, "stale.jsonl")
ag := agent.New(usageProvider{usage: &provider.Usage{}}, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
tab := &WorkspaceTab{
ID: "tab",
Ctrl: control.New(control.Options{Executor: ag, Sink: event.Discard, SessionDir: dir, SessionPath: rotated}),
}
// Telemetry still keyed to the pre-rotation session: a typed /new routes
// through Controller.Submit and rotates without App.NewSession running.
tab.syncTelemetryToSession(stale)
tab.recordUsage(costedUsageEvent())
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
info := app.ContextUsageForTab("tab")
if info.SessionCost != 0 || info.SessionTokens != 0 {
t.Fatalf("context after rotation = cost %f tokens %d, want zeros", info.SessionCost, info.SessionTokens)
}
if got := tab.telemetrySnapshot().Usage.RequestCount; got != 0 {
t.Fatalf("telemetry request count after rotation = %d, want 0", got)
}
}
func TestNewSessionResetsTabUsageTelemetry(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessPath := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "world"})
exec := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "tab",
}
tab := &WorkspaceTab{
ID: "tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: sessPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: exec,
SessionDir: dir,
SessionPath: sessPath,
Label: "test",
Sink: tab.sink,
})
app.tabs[tab.ID] = tab
tab.syncTelemetryToSession(sessPath)
tab.recordUsage(costedUsageEvent())
if seed := tab.telemetrySnapshot().Usage.SessionCost; seed <= 0 {
t.Fatalf("seed cost = %f, want positive", seed)
}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
if got := tab.telemetrySnapshot().Usage; got.SessionCost != 0 || got.RequestCount != 0 || got.TotalTokens != 0 {
t.Fatalf("telemetry after NewSession = %+v, want zeroed", got)
}
if info := app.ContextUsageForTab("tab"); info.SessionCost != 0 || info.SessionTokens != 0 {
t.Fatalf("context after NewSession = cost %f tokens %d, want zeros", info.SessionCost, info.SessionTokens)
}
}
func TestSnapshotConflictRecoveryCarriesTelemetryToFork(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
tab.syncTelemetryToSession(originalPath)
tab.recordUsage(costedUsageEvent())
want := tab.telemetrySnapshot().Usage.SessionCost
if want <= 0 {
t.Fatalf("seed cost = %f, want positive", want)
}
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
// The fork continues the conversation: in-memory totals carry over and a
// later sync against the fork path must not wipe them.
tab.syncTelemetryToSession(recoveryPath)
if got := tab.telemetrySnapshot().Usage.SessionCost; got != want {
t.Fatalf("carried cost = %f, want %f", got, want)
}
// The fork's sidecar was persisted at retarget time, so cost survives an
// app exit before the next usage event.
if got := loadTelemetry(recoveryPath + ".telemetry.json").Usage.SessionCost; got != want {
t.Fatalf("fork sidecar cost = %f, want %f", got, want)
}
}
+375
View File
@@ -491,6 +491,333 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
}
}
func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
topicID := "legacy_desktop-legacy_1234"
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
ID: agent.BranchID(sessionPath),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: topicID,
TopicTitle: "你是谁",
Turns: 1,
Preview: "who are you",
}); err != nil {
t.Fatalf("save meta: %v", err)
}
markTopicMigrationDone(dir)
repaired := migrateLegacySessionsIntoGlobalTopics(dir)
if len(repaired) != 1 || repaired[0] != topicID {
t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
}
nodes := NewApp().ListProjectTree()
var global *ProjectNode
for i := range nodes {
if nodes[i].Kind == "global_folder" {
global = &nodes[i]
break
}
}
if global == nil {
t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
}
if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
}
if got := loadTopicTitle("", topicID); got != "你是谁" {
t.Fatalf("global topic title = %q, want 你是谁", got)
}
}
func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
topicID := "legacy_desktop-delete_1234"
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
ID: agent.BranchID(sessionPath),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: topicID,
TopicTitle: "临时 Global",
Turns: 1,
Preview: "delete repaired topic",
}); err != nil {
t.Fatalf("save meta: %v", err)
}
markTopicMigrationDone(dir)
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
}
if err := NewApp().DeleteTopic(topicID); err != nil {
t.Fatalf("delete repaired topic: %v", err)
}
time.Sleep(10 * time.Millisecond)
later := time.Now()
if err := os.Chtimes(sessionPath, later, later); err != nil {
t.Fatalf("touch session after delete: %v", err)
}
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
t.Fatalf("deleted repaired topic was restored: %#v", repaired)
}
f := loadProjectsFile()
if containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
}
if got := loadTopicTitle("", topicID); got != "" {
t.Fatalf("global topic title = %q, want deleted", got)
}
if !containsDesktopString(f.DeletedTopics, topicID) {
t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
}
}
func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
writeIndexedSession := func(name, topicID, title string, at time.Time) string {
p := writeLegacySession(t, dir, name, "prompt "+title, at)
if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
ID: agent.BranchID(p),
CreatedAt: at.Add(-time.Hour),
UpdatedAt: at,
Scope: "global",
TopicID: topicID,
TopicTitle: title,
Turns: 1,
Preview: "prompt " + title,
}); err != nil {
t.Fatalf("save meta %s: %v", name, err)
}
if err := os.Chtimes(p, at, at); err != nil {
t.Fatalf("chtimes %s: %v", name, err)
}
return p
}
now := time.Now()
olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
markTopicMigrationDone(dir)
// First pass repairs both missing topics.
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
}
before := loadProjectsFile()
projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
statBefore, err := os.Stat(projPath)
if err != nil {
t.Fatalf("stat projects file: %v", err)
}
// Ordinary session activity invalidates the repair marker; the rescan must
// not reorder the indexed topics, rewrite the projects file, or report the
// already-visible topics as repaired (the callers bind blank Global tabs
// to repaired[0]).
time.Sleep(10 * time.Millisecond)
later := time.Now()
if err := os.Chtimes(olderPath, later, later); err != nil {
t.Fatalf("touch session: %v", err)
}
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
}
after := loadProjectsFile()
if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
}
statAfter, err := os.Stat(projPath)
if err != nil {
t.Fatalf("stat projects file: %v", err)
}
if !statAfter.ModTime().Equal(statBefore.ModTime()) {
t.Fatalf("steady-state rescan rewrote the projects file")
}
}
func TestBatchPrependRespectsTombstoneWrittenAfterScanSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
topicID := "legacy_race_000000000001"
// Simulate a DeleteTopic landing between a repair scan's DeletedTopics
// snapshot and its batch write: the tombstone exists by the time the
// prepend runs, so the batch must drop the topic instead of resurrecting it.
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
t.Fatalf("batch prepend: %v", err)
}
f := loadProjectsFile()
if containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
}
if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
}
if !containsDesktopString(f.DeletedTopics, topicID) {
t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
}
// Intentional single-topic writes (create/restore/tab indexing) clear the
// tombstone and bring the topic back in the same projects-file transaction.
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("single prepend: %v", err)
}
f = loadProjectsFile()
if !containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
}
if containsDesktopString(f.DeletedTopics, topicID) {
t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
}
}
func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
// Control: a legitimate title-only topic (not in GlobalTopics) must keep
// rendering through the orderedTopicIDs title-map fallback.
controlID := "topic_control_visible"
if err := setTopicTitle("", controlID, "正常话题"); err != nil {
t.Fatalf("set control title: %v", err)
}
// Race product: DeleteTopic landed, but a stale whole-map save wrote the
// topic's title back — tombstoned, absent from GlobalTopics, title present.
tombstonedID := "legacy_raced_000000000009"
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
t.Fatalf("set stale title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
nodes := NewApp().ListProjectTree()
var global *ProjectNode
for i := range nodes {
if nodes[i].Kind == "global_folder" {
global = &nodes[i]
break
}
}
if global == nil {
t.Fatalf("project tree = %#v, want Global folder", nodes)
}
seen := map[string]bool{}
for _, c := range global.Children {
seen[c.TopicID] = true
}
if seen[tombstonedID] {
t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
}
if !seen[controlID] {
t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
}
}
func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
// Stale race product on disk: tombstoned topic whose title lingers in the
// global title map. The next repair pass that saves the whole map must
// prune it instead of persisting it again.
tombstonedID := "legacy_raced_000000000010"
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
t.Fatalf("set stale title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
repairID := "legacy_desktop-prune_1234"
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
ID: agent.BranchID(sessionPath),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: repairID,
TopicTitle: "待修复话题",
Turns: 1,
Preview: "needs repair",
}); err != nil {
t.Fatalf("save meta: %v", err)
}
markTopicMigrationDone(dir)
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
}
if got := loadTopicTitle("", tombstonedID); got != "" {
t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
}
if got := loadTopicTitle("", repairID); got != "待修复话题" {
t.Fatalf("repaired title = %q, want 待修复话题", got)
}
f := loadProjectsFile()
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
}
if !containsDesktopString(f.GlobalTopics, repairID) {
t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
}
}
func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -1773,6 +2100,54 @@ func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
}
}
func TestTrashTopicRemovesStaleMissingSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_missing_trash"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Missing trash"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
missingPath := filepath.Join(dir, "already-gone.jsonl")
app := &App{
tabs: map[string]*WorkspaceTab{
"stale": {
ID: "stale",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Missing trash",
SessionPath: missingPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
},
tabOrder: []string{"stale", "other"},
activeTabID: "stale",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic should remove stale missing session: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("topic title should be removed, got %q", got)
}
if _, ok := app.tabs["stale"]; ok {
t.Fatalf("stale tab should be removed")
}
if got := app.activeTabID; got != "other" {
t.Fatalf("active tab = %q, want other", got)
}
}
func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
+40 -13
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -34,11 +35,11 @@ import (
// the thin Wails binding that wires these into App methods and progress events.
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
// worker release gateway. The build channel picks the rolling pointer so a
// canary build polls the canary line and a stable build polls latest; the two
// never cross. The gateway deliberately avoids GitHub's repository-wide
// /releases/latest shortcut because CLI tags (v*) and desktop tags (desktop-v*)
// are separate release lines in the same repo.
// worker release gateway, then GitHub as the stable channel's last resort. The
// build channel picks the rolling pointer so a canary build polls the canary
// line and a stable build polls latest; the two never cross. The gateway still
// avoids GitHub's repository-wide /releases/latest shortcut so the app is not
// coupled to GitHub's homepage badge semantics.
const (
r2Base = "https://dl.reasonix.io"
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
@@ -46,8 +47,18 @@ const (
httpTimeout = 15 * time.Second
)
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
// for the running build's channel.
// githubManifestFallback is the stable channel's last-resort manifest source.
// dl.reasonix.io and crash.reasonix.io share one Cloudflare zone, so bot
// protection that 403s a user's egress IP takes out both first-party endpoints
// at once (#6005); GitHub is separate infrastructure. Stable desktop releases
// own the repo-wide latest badge and publish latest.json directly, while
// release.yml also keeps a desktop-manifest mirror attached to stable CLI
// releases for older publishing windows. Canary has no GitHub release, so its
// chain stays two-deep.
const githubManifestFallback = "https://github.com/esengine/DeepSeek-Reasonix/releases/latest/download/latest.json"
// manifestEndpoints returns the manifest URLs for the running build's channel,
// in the order fetchManifest tries them.
func manifestEndpoints() []string {
if channel == "canary" {
return []string{
@@ -58,9 +69,18 @@ func manifestEndpoints() []string {
return []string{
r2Base + "/latest/latest.json",
releaseGatewayBase + "/stable/latest.json",
githubManifestFallback,
}
}
// updaterUserAgent identifies updater traffic. Go's default Go-http-client UA
// is exactly what edge bot protection scores worst (#6005); a descriptive UA
// lets the release edge allowlist updater requests and makes them attributable
// in server logs.
func updaterUserAgent() string {
return fmt.Sprintf("Reasonix-Updater/%s (%s/%s; %s)", version, runtime.GOOS, runtime.GOARCH, channel)
}
// downloadPage is the human-facing releases page shown when self-update is
// unavailable (macOS) or the manifest omits its own link.
func downloadPage() string {
@@ -147,24 +167,26 @@ func normalizeVersion(v string) (string, bool) {
return semver.Canonical(v), true
}
// fetchManifest pulls latest.json from the primary endpoint, then the fallback,
// and decodes it.
// fetchManifest pulls latest.json from each endpoint in order until one both
// responds and decodes. Every endpoint's failure is kept — a user staring at a
// gateway 403 (#6005) needs to see that the R2 pointer failed too, not just
// whichever endpoint happened to die last.
func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error) {
var lastErr error
var errs []error
for _, url := range manifestEndpoints() {
b, err := fetchBytes(ctx, c, url)
if err != nil {
lastErr = err
errs = append(errs, err)
continue
}
var m update.Manifest
if err := json.Unmarshal(b, &m); err != nil {
lastErr = err
errs = append(errs, fmt.Errorf("%s: %w", url, err))
continue
}
return &m, nil
}
return nil, fmt.Errorf("update: fetch manifest: %w", lastErr)
return nil, fmt.Errorf("update: fetch manifest: %w", errors.Join(errs...))
}
// evaluate compares the running version against the manifest and builds the
@@ -214,6 +236,9 @@ type cachedUpdate struct {
var updateCacheBaseDir = defaultUpdateCacheBaseDir
func defaultUpdateCacheBaseDir() (string, error) {
if cd := config.CacheDir(); cd != "" {
return filepath.Join(cd, "updates"), nil
}
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
@@ -426,6 +451,7 @@ func fetchBytesOnce(ctx context.Context, c *http.Client, url string) ([]byte, er
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", updaterUserAgent())
resp, err := c.Do(req)
if err != nil {
return nil, err
@@ -467,6 +493,7 @@ func downloadInto(ctx context.Context, c *http.Client, url string, buf *bytes.Bu
if err != nil {
return err
}
req.Header.Set("User-Agent", updaterUserAgent())
if buf.Len() > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", buf.Len()))
}
+8 -1
View File
@@ -101,7 +101,14 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
if stable[1] != releaseGatewayBase+"/stable/latest.json" {
t.Errorf("stable fallback = %q, want the release gateway", stable[1])
}
for _, u := range append(stable, canary...) {
// GitHub is stable's explicit last resort only (#6005: both first-party
// endpoints share one Cloudflare zone). Stable desktop releases own the
// repo-wide latest release and carry latest.json directly; no other slot may
// lean on repository-wide latest.
if len(stable) != 3 || stable[2] != githubManifestFallback {
t.Errorf("stable endpoints = %q, want the GitHub compatibility manifest last", stable)
}
for _, u := range append(stable[:2:2], canary...) {
if strings.Contains(u, "/releases/latest") {
t.Errorf("manifest endpoint uses GitHub's repository-wide latest release: %q", u)
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"testing"
"reasonix/internal/sandbox"
)
// The Windows bash sandbox relaunches os.Executable() with
// sandbox.WindowsHelperCommand as argv[1]. If the desktop binary loses this
// route, every sandboxed command on Windows starts a second GUI instance and
// returns empty output (#6051, #6067, #6072). These tests pin the route.
func TestWindowsSandboxHelperRouteRecognized(t *testing.T) {
// argv[2:] is empty, so the helper rejects it with a usage error — the
// point here is only that the route matched (ok == true) and the process
// would exit instead of booting the GUI.
code, ok := runWindowsSandboxHelperIfRequested([]string{"reasonix-desktop", sandbox.WindowsHelperCommand})
if !ok {
t.Fatal("helper subcommand not routed; sandboxed commands would boot a second GUI instance")
}
if code == 0 {
t.Fatalf("helper with no payload should fail with a usage error, got exit code %d", code)
}
}
func TestWindowsSandboxHelperRouteIgnoresNormalLaunch(t *testing.T) {
for _, argv := range [][]string{
{"reasonix-desktop"},
{"reasonix-desktop", "--some-flag"},
{"reasonix-desktop", "not-the-helper", sandbox.WindowsHelperCommand},
} {
if _, ok := runWindowsSandboxHelperIfRequested(argv); ok {
t.Fatalf("argv %v should not be treated as a helper launch", argv)
}
}
}
+46 -17
View File
@@ -30,8 +30,9 @@ type workspaceChangeAccumulator struct {
const workspaceGitBranchCacheTTL = 2 * time.Second
type workspaceGitBranchCacheEntry struct {
branch string
expires time.Time
branch string
expires time.Time
refreshing bool
}
var workspaceGitBranchCache = struct {
@@ -39,6 +40,8 @@ var workspaceGitBranchCache = struct {
entries map[string]workspaceGitBranchCacheEntry
}{entries: map[string]workspaceGitBranchCacheEntry{}}
var workspaceGitBranchForMetaProbe = workspaceGitBranch
func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
tabID = strings.TrimSpace(tabID)
@@ -245,32 +248,58 @@ func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
}
// workspaceGitBranchForMeta is the cached variant used by high-frequency UI
// metadata refreshes. Workflows that need an immediate git read, such as
// WorkspaceChanges, should call workspaceGitBranch directly.
// metadata refreshes. It never waits for git on the caller path: stale branch
// metadata is less harmful than blocking tab activation or hydration. Workflows
// that need an immediate git read, such as WorkspaceChanges, should call
// workspaceGitBranch directly.
func workspaceGitBranchForMeta(base string) string {
key := filepath.Clean(base)
now := time.Now()
workspaceGitBranchCache.Lock()
if cached, ok := workspaceGitBranchCache.entries[key]; ok && now.Before(cached.expires) {
if cached, ok := workspaceGitBranchCache.entries[key]; ok {
branch := cached.branch
if now.Before(cached.expires) || cached.refreshing {
workspaceGitBranchCache.Unlock()
return branch
}
cached.refreshing = true
workspaceGitBranchCache.entries[key] = cached
workspaceGitBranchCache.Unlock()
return cached.branch
go refreshWorkspaceGitBranchForMeta(key, base)
return branch
}
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{
expires: now.Add(workspaceGitBranchCacheTTL),
refreshing: true,
}
workspaceGitBranchCache.Unlock()
branch := workspaceGitBranch(base)
go refreshWorkspaceGitBranchForMeta(key, base)
return ""
}
storeNow := time.Now()
workspaceGitBranchCache.Lock()
if len(workspaceGitBranchCache.entries) > 256 {
for k, cached := range workspaceGitBranchCache.entries {
if storeNow.After(cached.expires) {
delete(workspaceGitBranchCache.entries, k)
func refreshWorkspaceGitBranchForMeta(key, base string) {
branch := ""
// Store via defer so the refreshing flag is always cleared, even when the
// probe panics or exits the goroutine early; otherwise the entry would stay
// marked refreshing forever and never update again.
defer func() {
storeNow := time.Now()
workspaceGitBranchCache.Lock()
if len(workspaceGitBranchCache.entries) > 256 {
for k, cached := range workspaceGitBranchCache.entries {
if storeNow.After(cached.expires) {
delete(workspaceGitBranchCache.entries, k)
}
}
}
}
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
workspaceGitBranchCache.Unlock()
return branch
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
workspaceGitBranchCache.Unlock()
}()
branch = workspaceGitBranchForMetaProbe(base)
}
// workspaceGitBranch returns the current git branch name for the repo rooted
+97
View File
@@ -3,9 +3,12 @@ package main
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"sync"
"testing"
"time"
)
func TestWorkspaceGitDisablesDaemonSpawns(t *testing.T) {
@@ -114,3 +117,97 @@ func TestWorkspaceGitBranchNonGitDirectory(t *testing.T) {
t.Fatalf("branch = %q, want empty", got)
}
}
func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
resetWorkspaceGitBranchMetaCacheForTest(t)
origProbe := workspaceGitBranchForMetaProbe
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
workspaceGitBranchForMetaProbe = func(string) string {
close(started)
<-release
return "feature/async"
}
defer func() {
releaseProbe()
workspaceGitBranchForMetaProbe = origProbe
}()
start := time.Now()
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe"); got != "" {
t.Fatalf("cold branch = %q, want empty while async refresh runs", got)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("cold metadata branch probe blocked for %s", elapsed)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("background branch refresh did not start")
}
releaseProbe()
eventuallyBranchForMeta(t, "/tmp/reasonix-cold-probe", "feature/async")
}
func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
resetWorkspaceGitBranchMetaCacheForTest(t)
workspaceGitBranchCache.Lock()
workspaceGitBranchCache.entries[filepath.Clean("/tmp/reasonix-stale-probe")] = workspaceGitBranchCacheEntry{
branch: "feature/stale",
expires: time.Now().Add(-time.Second),
}
workspaceGitBranchCache.Unlock()
origProbe := workspaceGitBranchForMetaProbe
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
workspaceGitBranchForMetaProbe = func(string) string {
close(started)
<-release
return "feature/fresh"
}
defer func() {
releaseProbe()
workspaceGitBranchForMetaProbe = origProbe
}()
start := time.Now()
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe"); got != "feature/stale" {
t.Fatalf("stale branch = %q, want feature/stale", got)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("stale metadata branch probe blocked for %s", elapsed)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("background branch refresh did not start")
}
releaseProbe()
eventuallyBranchForMeta(t, "/tmp/reasonix-stale-probe", "feature/fresh")
}
func resetWorkspaceGitBranchMetaCacheForTest(t *testing.T) {
t.Helper()
workspaceGitBranchCache.Lock()
workspaceGitBranchCache.entries = map[string]workspaceGitBranchCacheEntry{}
workspaceGitBranchCache.Unlock()
}
func eventuallyBranchForMeta(t *testing.T, base, want string) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := workspaceGitBranchForMeta(base); got == want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("branch did not refresh to %q", want)
}
+4
View File
@@ -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**,
+7
View File
@@ -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
View File
@@ -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
+15 -3
View File
@@ -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`,后者优先。
## 配置优先级
+6
View File
@@ -43,6 +43,12 @@ Reasonix 的 Goal 模式(`/goal`)在 v1.9+ 中增加了来自 Oh-My-OpenAgen
- 需要完整测试覆盖的任务
- 多步骤复杂流程
### 任务合约
复杂目标可以直接写成 Context / Request / Output format / Constraints /
Pause policy。Goal 模式会把这些段落当作执行边界:满足请求、输出格式、约束和必要验证后才结束;
除非下一步涉及不可逆或对外可见操作、范围变化,或必须由用户提供信息,否则继续采用合理默认值推进。
### 并行子任务
```bash
+51 -8
View File
@@ -16,6 +16,7 @@
- [Serve web frontend](#serve-web-frontend)
- [Configuration paths](./CONFIG_PATHS.md)
- [Reasoning language](./REASONING_LANGUAGE.md)
- [Task contracts and pause policy](./TASK_CONTRACT.md)
- [Custom OpenAI-compatible providers](#custom-openai-compatible-providers)
- [Desktop hooks](#desktop-hooks)
- [Keyboard shortcuts](#keyboard-shortcuts)
@@ -479,16 +480,51 @@ out. `forbid_read` optionally hides sensitive directories from the agent's
read/list/search tools; use absolute paths or `${HOME}` / `${VAR}` references,
not `~`, because config expansion is environment-variable based. `bash` is
itself jailed by default when an OS sandbox is available (`[sandbox] bash`,
Seatbelt on macOS and bubblewrap on Linux): commands may write only those same
roots (plus temp and toolchain caches), cannot read configured `forbid_read`
roots while the OS sandbox is active, and reach the network only when
`[sandbox] network` is set. When no OS sandbox is available, `bash = "enforce"`
refuses bash execution instead of running unconfined. Install the platform
sandbox backend (bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
Seatbelt on macOS, bubblewrap on Linux, and a native helper on Windows):
commands may write only those same roots plus platform-specific command
temp/cache roots, cannot read configured `forbid_read` roots while the OS
sandbox is active, and reach the network only when `[sandbox] network` is set.
The native Windows helper uses Reasonix's bundled Windows sandbox backend:
AppContainer for read-only commands and a low-integrity token for writable
commands, temporarily grants
access to the workspace, a per-command temp root, and the target executable,
applies deny ACEs for `forbid_read` (files as well as directories), snapshots
touched DACLs before editing them, and restores those snapshots best-effort
after the command exits. Concurrent commands touching the same workspace are
serialized so their ACL edits cannot corrupt each other, and residue from a
force-killed command (a lingering low-integrity label or `forbid_read` deny) is
cleaned up by the next run. Because a writable command runs under a
low-integrity token, it can still write the few locations Windows leaves
writable to any low-integrity process (for example `%USERPROFILE%\AppData\LocalLow`)
in addition to the configured roots; the workspace boundary and `forbid_read`
denials still hold. Read-only AppContainer commands omit network capabilities
when networking is disabled; writable Windows commands fail closed when
`[sandbox] network = false`.
When no OS sandbox backend is available, `bash = "enforce"` refuses bash
execution instead of running unconfined. Install the platform sandbox backend
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
behavior (see
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt and
broader OS support still to come).
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt
and optional elevated Windows hardening still to come).
Windows sandbox troubleshooting: the sandbox relaunches the Reasonix
executable as a hidden helper, and both the CLI and the desktop app embed that
helper entry point — if enforce is requested in a build that lacks it, bash
refuses with a clear error instead of returning empty output. A command that
queues behind another sandboxed command on the same workspace prints a
one-line "waiting for another sandboxed command" notice that names the holding
command and its PID when known. A foreground command gives up after 1 minute
with the same holder detail (a queued turn should fail fast, not hang);
background jobs wait up to 10 minutes, and `WINDOWS_SANDBOX_LOCK_MS` overrides
both. Stop the named command first; raising the wait cap only makes later
commands wait longer. If sandboxed commands fail
only under Git-for-Windows/MSYS2 bash, try `[tools.shell] prefer =
"powershell"` — the MSYS runtime is fragile under a low-integrity token. Run
`reasonix doctor` to see the resolved shell, sandbox availability, and whether
a project `reasonix.toml` pins `[sandbox]` (a project file overrides
Settings/user-config edits, and sandbox changes take effect after a session
config reload or a new session).
## Plugins (MCP)
@@ -662,6 +698,13 @@ skill in Settings -> Skills or the slash menu. If an ordinary chat prompt has a
very strong long-horizon signal, the host also upgrades it into the equivalent
of `/goal --research <original prompt>`.
For complex work, write the objective as a
[task contract](./TASK_CONTRACT.md): Context, Request, Output format,
Constraints, and Pause policy. Goal mode treats those sections as the boundary
for autonomous work. It keeps going with sensible defaults unless the next step
requires an irreversible or externally visible operation, a scope change, or
information only the user can provide.
AutoResearch is enabled for goals with strong signals such as "keep
researching", "long-running", "thoroughly", "debug until the root cause is
clear", "do not spin", "run experiments", "verify repeatedly", or "turn this
+32 -3
View File
@@ -16,6 +16,7 @@
- [Serve Web 前端](#serve-web-前端)
- [配置路径](./CONFIG_PATHS.zh-CN.md)
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
- [自定义 OpenAI-compatible provider](#自定义-openai-compatible-provider)
- [桌面端 Hooks](./DESKTOP_HOOKS.zh-CN.md)
- [快捷键](#快捷键)
@@ -391,11 +392,35 @@ CJK 双宽字符,造成视觉错位。想保留旧的终端块状光标可设
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
打洞越界。`forbid_read` 可选地隐藏敏感目录,使 agent 的读文件、列目录和搜索工具不能读取或列出它们;
建议使用绝对路径或 `${HOME}` / `${VAR}`,不要写 `~`,因为配置只做环境变量展开。
`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):命令只能写这些 root(外加临时目录与工具链缓存),
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网;
其它平台在没有可用 OS 沙盒时会回退为不沙盒运行(越界问一次与 Linux 支持见
`bash` 本身默认进 OS 沙盒(`[sandbox] bash`:macOS 使用 Seatbelt,Linux 使用 bubblewrap,
原生 Windows 使用 native helper):命令只能写这些 root(外加平台按命令提供的临时/缓存 root),
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网。
原生 Windows helper 使用 Reasonix 内置的 Windows sandbox backend:
只读命令使用 AppContainer,可写命令使用 low-integrity token;它会临时授予
workspace、每次命令专用 temp root 和目标可执行文件的访问权,对 `forbid_read`
(文件和目录皆可)临时添加 deny ACE,修改前记录被触碰目录的 DACL,命令结束后尽力恢复。
作用于同一 workspace 的并发命令会被串行化,避免各自的 ACL 修改互相破坏;被强杀命令
残留的 low-integrity 标签或 `forbid_read` deny ACE 会由下一次运行清理。由于可写命令跑在
low-integrity token 下,除配置的 root 外它仍能写入 Windows 对任何 low-integrity 进程开放的
少数位置(例如 `%USERPROFILE%\AppData\LocalLow`),但 workspace 边界与 `forbid_read`
拒绝依然有效。只读 AppContainer 命令在关闭网络时不给 network capability;可写 Windows 命令遇到
`[sandbox] network = false` 时会 fail closed。没有可用 OS 沙盒时,`bash = "enforce"` 会拒绝 bash 执行,不会无沙盒运行
(越界询问与可选的 Windows elevated 加固见
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope))。
Windows 沙盒排障:沙盒会把 Reasonix 可执行文件自身以隐藏 helper 方式重新拉起,
CLI 与桌面端都内置了这个 helper 入口——若某个构建缺少入口而又请求 enforce,
bash 会以明确报错拒绝执行,而不是返回空输出。同一 workspace 上排队等待另一条
沙盒命令时会打印一行“waiting for another sandboxed command”提示,并在可识别时
标出持锁命令及其 PID。前台命令排队 1 分钟后即失败并给出同样的持锁信息(被挡住
的回合应尽快报错,而不是挂住);后台任务最多等 10 分钟,`WINDOWS_SANDBOX_LOCK_MS`
可同时覆盖两者。应先停止提示中点名的命令;调大等待上限只会让后续命令等更久。
如果只有 Git-for-Windows/MSYS2 bash
下的沙盒命令失败,可试 `[tools.shell] prefer = "powershell"`——MSYS 运行时在
low-integrity token 下较脆弱。运行 `reasonix doctor` 可查看解析到的 shell、沙盒
可用性,以及项目 `reasonix.toml` 是否固定了 `[sandbox]`(项目文件优先级高于
Settings/用户配置;沙盒配置变更需 reload session config 或新开会话才生效)。
## 插件(MCP)
Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdio`(默认)启动本地子进
@@ -529,6 +554,10 @@ Goal 是长期目标的统一运行机制。普通 `/goal` 继续走轻量 Goal
Settings -> Skills 或斜杠菜单里。普通聊天输入如果命中很强的长周期信号,也会被 host 自动
升级为等价的 `/goal --research <原输入>`。
复杂任务建议把目标写成[任务合约](./TASK_CONTRACT.zh-CN.md):Context、Request、
Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当作自主执行的边界;
除非下一步需要不可逆或对外可见操作、任务范围变化,或必须由用户提供信息,否则会继续采用合理默认值推进,并在最后汇报假设与结果。
AutoResearch 会在这些目标里自动启用:包含“持续”“长期”“彻底”“直到根因明确”“多轮排查”
“不要原地打转”“完整方案”“跑实验”“反复验证”“系统性研究”等强信号;或者目标同时包含
研究/排查、实现/修复、验证/测试、优化/文档/发布等多个阶段;或者用户明确给出
+9 -10
View File
@@ -11,7 +11,7 @@ changed and how to move over.
| Language | TypeScript / Node | Go |
| Branch | [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) (maintenance only) | `main-v2` (default, active) |
| Versions | `0.x` (up to v0.54.x) | `1.0.0`+ |
| Install | `npm i -g reasonix` (the `latest` tag, stays on `0.x`) | `npm i -g reasonix@next` — `latest` deliberately stays on `0.x`; or a release archive / `go build` |
| Install | `npm i -g reasonix@0.53.2` (pin a `0.x` version) | `npm i -g reasonix` — `latest` points at the current `1.x` stable; or a release archive / `go build` |
| Code intelligence | embedding semantic search + tree-sitter symbols | LSP-assisted code reading plus grep/read_file/glob; semantic index is not yet ported |
"v1" and "v2" are **codebase generations**, not semver: the v1 line never reached
@@ -23,19 +23,18 @@ changed and how to move over.
same way esbuild/biome ship native binaries via npm). The binary itself is a
standalone Go executable; npm is only the installer, not a runtime dependency.
**`npm i -g reasonix` deliberately still installs `0.x`.** A bare install — and
`npx reasonix`, and 0.53's own `update` — follows npm's `latest` tag, which we
keep pinned to the `0.x` line so existing users aren't pulled into the rewrite
without asking. v1.x (Go) ships under the `next` tag; opt in explicitly:
**`npm i -g reasonix` installs the current `1.x` stable.** npm's `latest` tag
moved to the Go line with `1.17.5` — the earlier "`latest` stays pinned to
`0.x`" migration guard silently downgraded `npm update -g` users once 1.x went
stable (#5822), so it was retired. Release candidates still ship under the
`next` tag; `0.x` stays installable by pinning:
```sh
npm i -g reasonix@next # or pin a version: reasonix@1.1.0
reasonix
npm i -g reasonix # current 1.x stable
npm i -g reasonix@next # release candidate, when one is ahead of stable
npm i -g reasonix@0.53.2 # pin the legacy TS build
```
`latest` will stay on `0.x` for the foreseeable future, so installing or
updating v2 always means `@next` (or a pinned `1.x`).
Prebuilt archives (`reasonix-<os>-<arch>.tar.gz` / `.zip`) and the desktop
installer are attached to each GitHub release. These are a **separate channel**
from npm: the installer drops a standalone desktop/binary build and does not
+10 -7
View File
@@ -17,7 +17,7 @@ provides the pre-release buffer instead of a long-lived branch.
| Surface | Stable | Pre-release buffer |
|---|---|---|
| npm | `latest` (0.x), `next` (1.x) | `canary` (`npm i reasonix@canary`) |
| npm | `latest` (current 1.x stable) | `next` (rc), `canary` (`npm i reasonix@canary`) |
| Desktop | R2 `latest/` pointer + release gateway | R2 `canary/` pointer + release gateway proxy (never on the GitHub releases page) |
A canary build is isolated: it **never** moves `latest` / `next` / desktop `latest/`.
@@ -53,15 +53,18 @@ the `release` environment deployment.
5. **Ship stable** when the canary is clean — push the three tags:
```sh
git tag v1.4.0 && git push origin v1.4.0 # CLI binaries + Homebrew
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> next
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> latest
git tag desktop-v1.4.0 && git push origin desktop-v1.4.0 # desktop -> R2 latest/
```
Each stable run **waits for esengine to approve the `release` environment** before publishing.
6. **Promote to default install** (optional, when 1.x should become the bare `npm i` target):
```sh
npm dist-tag add reasonix@1.4.0 latest
```
7. **Next cycle** — the canary rolls on toward `1.5.0`.
A stable `npm-v*` publish moves the `latest` dist-tag automatically (build.mjs)
and release-npm.yml verifies it landed. **Do not skip the npm tag**: the stable
CLI release (release.yml) fails when the matching `npm-v*` tag was never pushed
— that guard exists because 1.0.0–1.17.5 shipped without stable npm tags and
`npm update -g` silently downgraded users to 0.53.2 (#5822). A pushed tag whose
publish is still awaiting approval only warns; release-npm.yml's verify step
owns asserting the dist-tag lands.
6. **Next cycle** — the canary rolls on toward `1.5.0`.
## Notes
+39 -8
View File
@@ -362,7 +362,17 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
model reports completion, repeats the same blocked state three times, the user
stops it, or the safety continuation limit is reached. Blocked-state matching
is normalized for casing, whitespace, and punctuation so minor wording drift
does not reset the audit; restarting a goal begins a fresh blocked audit.
does not reset the audit; restarting a goal begins a fresh blocked audit. A
goal is treated as a task contract: if the objective includes Context,
Request, Output format, Constraints, or Pause policy sections, those sections
define the autonomous work boundary. When they are absent, the model infers a
lightweight contract from the conversation and workspace. The injected goal
block tells the model to pause only for irreversible or externally visible
operations, scope changes, or information only the user can provide; ordinary
uncertainty should be handled with sensible defaults and reported as an
assumption. Completion requires the concrete request, output format,
constraints, and relevant verification expectations to be satisfied or
explicitly reported as unverified.
Goals that look like long-horizon research, debugging, optimization, or
implementation work automatically add an AutoResearch protocol to the same
transient active-goal user block. AutoResearch is a Goal strategy, not a
@@ -646,9 +656,29 @@ not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansio
and should be absolute, or use `${HOME}` for home-relative secrets such as
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS, bubblewrap on
Linux): each command is allowed to write only the same roots (+ temp and
toolchain caches), denied reads under `forbid_read`, and allowed to reach the
network only when `network = true`.
Linux, and a native helper on Windows): each command is allowed to write only
the same roots plus platform-specific command temp/cache roots, denied reads
under `forbid_read`, and allowed to reach the network only when
`network = true`.
The native Windows helper uses Reasonix's bundled Windows sandbox backend:
AppContainer for read-only commands and a low-integrity token for writable
commands, with temporary ACL grants for
writable roots and tool executables, a per-command temp root instead of mutating
the global Temp directory, temporary deny ACEs for `forbid_read` (files and
directories), best-effort restoration from pre-run DACL snapshots for touched
directories, and a kill-on-close Job Object. Because the sandbox works by
temporarily mutating shared-path ACLs and integrity labels, concurrent commands
against the same root are serialized with a per-root lock, and residue from a
force-killed command (a lingering low-integrity label or `forbid_read` deny ACE)
is swept by the next run so a crash cannot durably lower a workspace's integrity
or lock the user out of a `forbid_read` path. A writable command runs under a
low-integrity token, so beyond the configured roots it retains write access to
the narrow set of locations Windows leaves writable to any low-integrity process
(e.g. `AppData\LocalLow`); the workspace boundary and `forbid_read` denials are
unaffected. Read-only AppContainer commands omit network capabilities when
networking is disabled; writable Windows commands fail closed when
`network = false` because the low-integrity token does not provide a reliable
per-process network block without elevated firewall/WFP setup.
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
instead of running unconfined. Install the platform sandbox backend
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
@@ -681,14 +711,15 @@ behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9)
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
macOS, bubblewrap on Linux, and a native Windows helper ship, on by default
when available** (see §5).
Remaining: (a)
the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
command just fails and the model adapts), which completes the "allow inside the
box, prompt at its edge" model; (b) broader OS sandbox support beyond current
Seatbelt/bubblewrap hosts. Shells out to OS tooling so the binary stays
dependency-free. With this in
box, prompt at its edge" model; (b) an optional elevated Windows backend with a
dedicated sandbox user for enterprise hardening. Shells out to OS tooling so
the binary stays dependency-free. With this in
place, "always allow" rule persistence becomes optional rather than load-bearing.
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
+83
View File
@@ -0,0 +1,83 @@
# Task Contracts and Pause Policy
<a href="./GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<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.
```
+69
View File
@@ -0,0 +1,69 @@
# 任务合约与暂停策略
<a href="./GUIDE.zh-CN.md">使用指南</a>
&nbsp;·&nbsp;
<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 或凭证,否则继续完成实现和验证后再汇报。
```
+19
View File
@@ -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 {
+51
View File
@@ -9,6 +9,7 @@ import (
"time"
"unicode/utf8"
"reasonix/internal/control"
"reasonix/internal/event"
)
@@ -306,6 +307,56 @@ func TestUpdateSinkApprovalBashPrefix(t *testing.T) {
}
}
func TestUpdateSinkSandboxEscapeApprovalOffersSessionGrant(t *testing.T) {
fn := &fakeNotifier{onReq: func(_ string, params any) (json.RawMessage, error) {
raw, _ := json.Marshal(params)
var p PermissionRequestParams
if err := json.Unmarshal(raw, &p); err != nil {
t.Fatalf("permission params: %v", err)
}
assertACPv1PermissionOptionKinds(t, p.Options)
var hasOnce, hasSession, hasReject bool
for _, opt := range p.Options {
switch opt.OptionID {
case string(OptAllowOnce):
hasOnce = opt.Kind == OptAllowOnce
case string(OptAllowAlways):
hasSession = opt.Kind == OptAllowAlways && opt.Name == "Use real environment for this session"
case string(OptRejectOnce):
hasReject = opt.Kind == OptRejectOnce
default:
t.Fatalf("unexpected ACP permission option %+v in %+v", opt, p.Options)
}
}
if len(p.Options) != 3 || !hasOnce || !hasSession || !hasReject {
t.Fatalf("options = %+v, want allow once, session, reject", p.Options)
}
res, _ := json.Marshal(PermissionRequestResult{
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
})
return res, nil
}}
sink := newUpdateSink(fn, "sess-1")
got := make(chan approveCall, 1)
sink.bindApprove(func(id string, allow, session, persist bool) { got <- approveCall{id, allow, session, persist} })
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "11",
Tool: control.SandboxEscapeApprovalTool,
Subject: "run unconfined once: go test ./...",
}})
select {
case c := <-got:
want := approveCall{id: "11", allow: true, session: true, persist: false}
if c != want {
t.Errorf("approve = %+v, want %+v", c, want)
}
case <-time.After(2 * time.Second):
t.Fatal("approve was never called")
}
}
func TestUpdateSinkApprovalDenied(t *testing.T) {
// Both a "cancelled" outcome and a transport error must deny the call.
for _, tc := range []struct {
+1 -1
View File
@@ -98,7 +98,7 @@ func (f *configurableFactory) NewSession(_ context.Context, p SessionParams) (*c
return behavior(ctx, sink, input, p)
},
}
opts := control.Options{Runner: runner, Sink: p.Sink, SessionDir: f.dir}
opts := control.Options{Runner: runner, Sink: p.Sink, SessionDir: f.dir, OnSessionRecovered: p.OnSessionRecovered}
if f.withHooks {
opts.Hooks = f.hookRunner()
}
+244 -27
View File
@@ -35,13 +35,16 @@ import (
// Cwd roots the session's file tools and bash (built via builtin.Workspace).
// Model and EffortOverride are optional session-local provider selectors from
// ACP config options. MCPServers are the MCP servers the client asked the agent
// to connect for this session.
// to connect for this session. OnSessionRecovered is the service's bookkeeping
// hook for automatic transcript recovery branches (see sessionRecoveredHandler);
// factories must wire it into the controller they build.
type SessionParams struct {
Cwd string
MCPServers []plugin.Spec
Sink event.Sink
Model string
EffortOverride *string
Cwd string
MCPServers []plugin.Spec
Sink event.Sink
Model string
EffortOverride *string
OnSessionRecovered func(control.SessionRecoveryInfo) error
}
// Factory builds the per-session controller. The composition root (the cli's
@@ -168,6 +171,13 @@ type acpSession struct {
done chan struct{}
running bool
deleted bool
// lease is the session lease guarding transcript against other runtimes
// (a desktop window, the CLI) for the life of this session. Held from
// session/new / session/load and released on close/delete/teardown.
// Config rebuilds keep the same transcript; when a snapshot conflict
// retargets the controller to a recovery branch, sessionRecoveredHandler
// moves transcript and this lease to the recovery file at commit time.
lease *agent.SessionLease
// maintenanceDone is non-nil while session-owned maintenance, such as an
// idle config rebuild, is in flight outside mu.
maintenanceDone chan struct{}
@@ -273,6 +283,104 @@ func (s *acpSession) currentCtrl() acpController {
return s.ctrl
}
// releaseSessionLease drops the session's transcript lease, if any. Idempotent.
func (s *acpSession) releaseSessionLease() {
s.mu.Lock()
lease := s.lease
s.lease = nil
s.mu.Unlock()
if lease != nil {
lease.Release()
}
}
// sessionLeaseBindError maps a lease-acquisition failure to the protocol
// error the client sees: a held session names its holder with the shared CLI
// wording; anything else is an internal error.
func sessionLeaseBindError(method string, err error) *RPCError {
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return &RPCError{
Code: ErrInvalidRequest,
Message: method + ": " + control.SessionInUseMessage(err) + "; " + control.SessionLeaseCloseHint,
}
}
return &RPCError{Code: ErrInternal, Message: method + ": session lease: " + err.Error()}
}
// sessionRecoveredHandler returns the OnSessionRecovered callback wired into
// every controller built for session id. When a snapshot conflict retargets
// the controller to a recovery branch (turn-end autosave in persistAfterTurn,
// or the pre-rebuild snapshot in rebuildSession), the ACP bookkeeping must
// follow at commit time: session/prompt reports sess.transcript,
// session/delete destroys it, and the session lease must guard the file the
// controller actually writes. The recovery lease is acquired before the old
// one is released so the outgoing transcript stays guarded until the new one
// is secured; a failure aborts the recovery commit and the controller stays
// on the original path (the next save retries).
func (s *service) sessionRecoveredHandler(id string) func(control.SessionRecoveryInfo) error {
return func(info control.SessionRecoveryInfo) error {
recoveryPath := strings.TrimSpace(info.RecoveryPath)
if recoveryPath == "" {
return nil
}
sess := s.session(id)
if sess == nil {
return nil
}
lease, err := agent.TryAcquireSessionLease(recoveryPath)
if err != nil {
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return fmt.Errorf("bind recovery session: %s; %s",
control.SessionInUseMessage(err), control.SessionLeaseCloseHint)
}
return fmt.Errorf("bind recovery session: %w", err)
}
sess.mu.Lock()
if sess.deleted {
sess.mu.Unlock()
lease.Release()
return fmt.Errorf("bind recovery session: session is deleted")
}
old := sess.lease
sess.lease = lease
sess.transcript = recoveryPath
meta := sess.metaLocked()
sess.mu.Unlock()
if old != nil {
old.Release()
}
_ = saveACPMeta(recoveryPath, meta)
// Leave a redirect on the id-keyed sidecar so restart-time lookups
// (session/load, session/resume, session/delete, loadMeta) resolve the
// id to the recovery file; without it the next process reopens the
// pre-recovery transcript. Always written against the id-keyed path,
// so resolution stays a single hop even for recovery-of-recovery.
if dir := s.sessionDir(); dir != "" {
if idPath := transcriptPath(dir, id); idPath != recoveryPath {
idMeta, _, err := loadACPMeta(idPath)
if err != nil {
slog.Warn("acp: load id-keyed meta for recovery redirect", "err", err)
idMeta = acpSessionMeta{}
}
if idMeta.SessionID == "" {
idMeta.SessionID = id
}
if idMeta.Cwd == "" {
idMeta.Cwd = meta.Cwd
}
if idMeta.CreatedAt.IsZero() {
idMeta.CreatedAt = meta.CreatedAt
}
idMeta.ActiveTranscript = filepath.Base(recoveryPath)
if err := saveACPMeta(idPath, idMeta); err != nil {
slog.Warn("acp: save recovery redirect", "err", err)
}
}
}
return nil
}
}
// initialize advertises the agent's capability set: persisted load plus ACP v1
// list/resume/close/delete lifecycle helpers, prompts carrying inline resource
// text (embeddedContext) but not image/audio, and stdio / Streamable HTTP MCP
@@ -352,11 +460,12 @@ func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, err
sink := newUpdateSink(s.conn, id)
ctrl, err := s.factory.NewSession(ctx, SessionParams{
Cwd: cwd,
MCPServers: mcpServers,
Sink: sink,
Model: cfgState.Model,
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
Cwd: cwd,
MCPServers: mcpServers,
Sink: sink,
Model: cfgState.Model,
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
OnSessionRecovered: s.sessionRecoveredHandler(id),
})
if err != nil {
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
@@ -379,9 +488,17 @@ func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, err
}
// Pin a transcript file keyed by session id when the controller has a session
// dir, so every turn auto-saves there, session/prompt can hand the path back,
// and session/load can find it again by id across process restarts.
// and session/load can find it again by id across process restarts. The
// session lease is taken with it (defensive: the id-keyed path is brand new)
// so no other runtime can bind the transcript while this session lives.
if dir := ctrl.SessionDir(); dir != "" {
sess.transcript = transcriptPath(dir, id)
lease, err := agent.TryAcquireSessionLease(sess.transcript)
if err != nil {
ctrl.Close()
return nil, sessionLeaseBindError("session/new", err)
}
sess.lease = lease
ctrl.SetSessionPath(sess.transcript)
}
@@ -459,7 +576,7 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
var saved acpSessionMeta
persistedPath := ""
if dir := s.sessionDir(); dir != "" {
persistedPath = transcriptPath(dir, id)
persistedPath = resolveTranscriptPath(dir, id)
if agent.IsCleanupPending(persistedPath) {
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
}
@@ -484,11 +601,12 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
sink := newUpdateSink(s.conn, id)
ctrl, err := s.factory.NewSession(ctx, SessionParams{
Cwd: cwd,
MCPServers: mcpServers,
Sink: sink,
Model: cfgState.Model,
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
Cwd: cwd,
MCPServers: mcpServers,
Sink: sink,
Model: cfgState.Model,
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
OnSessionRecovered: s.sessionRecoveredHandler(id),
})
if err != nil {
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
@@ -502,13 +620,22 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
ctrl.Close()
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": persistence is disabled"}
}
path := transcriptPath(dir, id)
path := resolveTranscriptPath(dir, id)
if path != persistedPath && agent.IsCleanupPending(path) {
ctrl.Close()
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
}
// Bind the transcript for writing only if no other runtime (a desktop
// window, the CLI) holds it; the editor should not silently double-write a
// session that is open elsewhere.
lease, leaseErr := agent.TryAcquireSessionLease(path)
if leaseErr != nil {
ctrl.Close()
return SessionConfigState{}, sessionLeaseBindError(method, leaseErr)
}
loaded, err := agent.LoadSession(path)
if err != nil {
lease.Release()
ctrl.Close()
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
}
@@ -529,8 +656,10 @@ func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam
title: meta.Title,
createdAt: meta.CreatedAt,
updatedAt: meta.UpdatedAt,
lease: lease,
}
if err := saveACPMeta(path, sess.meta()); err != nil {
sess.releaseSessionLease()
ctrl.Close()
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
}
@@ -552,6 +681,38 @@ func transcriptPath(dir, id string) string {
return filepath.Join(dir, id+".jsonl")
}
// resolveTranscriptPath returns the transcript file session id currently
// lives in. That is the id-keyed path by default; after a snapshot recovery
// moved the live session onto a recovery branch, the id-keyed sidecar carries
// an ActiveTranscript redirect (written by sessionRecoveredHandler) that
// load/resume/delete/meta lookups must follow, or a restart silently reopens
// the pre-recovery transcript. The redirect is a basename, must stay inside
// dir, and its target must exist and claim the same session id; anything else
// falls back to the id-keyed path.
func resolveTranscriptPath(dir, id string) string {
path := transcriptPath(dir, id)
meta, ok, err := loadACPMeta(path)
if err != nil || !ok {
return path
}
active := strings.TrimSpace(meta.ActiveTranscript)
if active == "" || active == filepath.Base(path) {
return path
}
if filepath.Base(active) != active {
return path
}
resolved := filepath.Join(dir, active)
if !sessionFileExists(resolved) {
return path
}
targetMeta, ok, err := loadACPMeta(resolved)
if err != nil || !ok || targetMeta.SessionID != id {
return path
}
return resolved
}
// sessionPrompt runs one turn. It flattens the prompt blocks to text and runs the
// session's controller synchronously under a per-turn cancelable context (so
// session/cancel can stop it), then reports why the turn ended. The controller
@@ -718,7 +879,6 @@ func (s *service) rebuildSession(ctx context.Context, sess *acpSession, cfgState
sess.pendingConfig = nil
cur := sess.ctrl
prevPath := cur.SessionPath()
sink := sess.sink
mcpServers := clonePluginSpecs(sess.mcpServers)
cwd := sess.cwd
@@ -735,14 +895,24 @@ func (s *service) rebuildSession(ctx context.Context, sess *acpSession, cfgState
if err := cur.Snapshot(); err != nil {
return &RPCError{Code: ErrInternal, Message: "session config: snapshot before switch: " + err.Error()}
}
// Capture the adopt path and history only after Snapshot: a snapshot
// conflict can retarget cur to a recovery branch (or adopt the newer disk
// transcript), and a pre-snapshot capture would bind the rebuilt controller
// back to the original file, re-conflicting on every later save. When that
// recovery fired, sessionRecoveredHandler already moved sess.transcript
// and the session lease to the recovery file, so prevPath, the session
// bookkeeping, and the controller agree on one path here.
// SessionPath is controller-locked, so reading it off sess.mu is safe.
prevPath := cur.SessionPath()
carried := cur.History()
newCtrl, err := s.factory.NewSession(ctx, SessionParams{
Cwd: cwd,
MCPServers: mcpServers,
Sink: sink,
Model: cfgState.Model,
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
Cwd: cwd,
MCPServers: mcpServers,
Sink: sink,
Model: cfgState.Model,
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
OnSessionRecovered: s.sessionRecoveredHandler(sess.id),
})
if err != nil {
return &RPCError{Code: ErrInternal, Message: "session config: " + err.Error()}
@@ -856,6 +1026,7 @@ func (s *service) sessionClose(_ context.Context, raw json.RawMessage) (any, err
if sess := s.takeSession(p.SessionID); sess != nil {
sess.abortAndWait()
sess.ctrl.Close()
sess.releaseSessionLease()
}
return SessionCloseResult{}, nil
}
@@ -883,7 +1054,18 @@ func (s *service) sessionList(_ context.Context, raw json.RawMessage) (any, erro
if err != nil {
return nil, &RPCError{Code: ErrInternal, Message: "session/list: " + err.Error()}
}
// A recovered session has two sidecars claiming the same id: the
// active recovery transcript's own meta and the id-keyed redirect.
// Reduce to one representative per id before filtering, so the entry
// shown never carries the stale pre-recovery title/timestamps.
best := map[string]acpSessionMeta{}
for _, meta := range metas {
cur, ok := best[meta.SessionID]
if !ok || listMetaBeats(meta, cur) {
best[meta.SessionID] = meta
}
}
for _, meta := range best {
info := meta.info(nil)
if sessionInfoMatchesCwd(info, filterCwd) {
byID[info.SessionID] = info
@@ -928,6 +1110,10 @@ func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, er
var delayed bool
if sess := s.takeSession(p.SessionID); sess != nil {
sess.deleteAndWait()
// The session is going away; drop its lease before removing files so
// the lease sidecars retire with the release (they are not in
// SessionSidecarFiles and would otherwise linger).
sess.releaseSessionLease()
path = sess.transcript
destroy = sess.ctrl.BeginDestroySession(path)
if result := destroy.Wait(); result.HasTimedOut() {
@@ -943,7 +1129,7 @@ func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, er
}
if path == "" {
if dir := s.sessionDir(); dir != "" {
path = transcriptPath(dir, p.SessionID)
path = resolveTranscriptPath(dir, p.SessionID)
}
}
if path != "" && !delayed {
@@ -954,6 +1140,17 @@ func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, er
destroy.Finish()
}
}
// A recovered session lives in two files: the recovery transcript (deleted
// above) and the id-keyed original holding the redirect. Remove the twin
// too, or it resurfaces in session/list as a ghost that delete-by-id can
// never reach again.
if dir := s.sessionDir(); dir != "" {
if idPath := transcriptPath(dir, p.SessionID); idPath != path {
if err := deleteSessionFiles(idPath); err != nil {
return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
}
}
}
return SessionDeleteResult{}, nil
}
@@ -1134,7 +1331,7 @@ func (s *service) loadMeta(id string) (acpSessionMeta, bool) {
if dir == "" {
return acpSessionMeta{}, false
}
meta, ok, err := loadACPMeta(transcriptPath(dir, id))
meta, ok, err := loadACPMeta(resolveTranscriptPath(dir, id))
if err != nil {
return acpSessionMeta{}, false
}
@@ -1151,6 +1348,7 @@ func (s *service) closeAll() {
for _, sess := range sessions {
sess.abortAndWait()
sess.currentCtrl().Close()
sess.releaseSessionLease()
}
}
@@ -1323,6 +1521,12 @@ type acpSessionMeta struct {
Title string `json:"title,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// ActiveTranscript, when set on the id-keyed sidecar, is the basename of
// the transcript this session currently lives in: a snapshot recovery
// moved the live session onto a recovery branch and left this redirect
// behind so restart-time lookups (resolveTranscriptPath) follow the
// session instead of reopening the pre-recovery file.
ActiveTranscript string `json:"activeTranscript,omitempty"`
}
func (m acpSessionMeta) info(extra map[string]any) SessionInfo {
@@ -1488,6 +1692,19 @@ func sessionIDFromTranscript(path string) string {
return base
}
// listMetaBeats reports whether a should represent its session id in
// session/list over b. A meta without an ActiveTranscript redirect is the
// session's live transcript and always beats a redirect sidecar; between two
// of the same kind the later UpdatedAt wins.
func listMetaBeats(a, b acpSessionMeta) bool {
aRedirect := strings.TrimSpace(a.ActiveTranscript) != ""
bRedirect := strings.TrimSpace(b.ActiveTranscript) != ""
if aRedirect != bRedirect {
return !aRedirect
}
return a.UpdatedAt.After(b.UpdatedAt)
}
func sessionInfoMatchesCwd(info SessionInfo, filter string) bool {
if filter == "" {
return true
+93
View File
@@ -0,0 +1,93 @@
package acp
import (
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/permission"
"reasonix/internal/provider"
)
func leaseTestFactory(dir string) *e2eFactory {
return &e2eFactory{
prov: &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "unused"}, {Type: provider.ChunkDone}},
}},
tool: fakeTool{name: "peek", ro: true, out: "unused"},
policy: permission.New("ask", nil, nil, nil),
sessionDir: dir,
}
}
// TestSessionLoadRefusedWhenLeaseHeld proves session/load returns a protocol
// error naming the holder when the transcript is owned by another runtime
// (a desktop window or CLI writing the same session), and does not register
// the session.
func TestSessionLoadRefusedWhenLeaseHeld(t *testing.T) {
dir := t.TempDir()
const id = "sess-held-lease"
path := transcriptPath(dir, id)
s := agent.NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "editor session"})
if err := s.Save(path); err != nil {
t.Fatal(err)
}
holder, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("test holder acquire: %v", err)
}
defer holder.Release()
client, stop := startServer(t, leaseTestFactory(dir))
defer stop()
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
resp := client.call(t, "session/load", SessionLoadParams{SessionID: id, Cwd: t.TempDir()})
if resp.Error == nil {
t.Fatalf("session/load of a held session succeeded, want protocol error")
}
if !strings.Contains(resp.Error.Message, "in use by another Reasonix") {
t.Fatalf("session/load error = %q, want holder wording", resp.Error.Message)
}
if strings.Contains(resp.Error.Message, path) {
t.Fatalf("session/load error leaks the session path: %q", resp.Error.Message)
}
// The refused load must not have bound the session: a prompt is unknown.
promptResp := client.call(t, "session/prompt", SessionPromptParams{
SessionID: id,
Prompt: []ContentBlock{{Type: "text", Text: "should not run"}},
})
if promptResp.Error == nil {
t.Fatalf("session/prompt after refused load succeeded, want unknown session")
}
}
// TestSessionCloseReleasesLease proves the lease taken by session/new is
// released by session/close, so another runtime can bind the transcript.
func TestSessionCloseReleasesLease(t *testing.T) {
dir := t.TempDir()
client, stop := startServer(t, leaseTestFactory(dir))
defer stop()
sid := openSession(t, client)
path := transcriptPath(dir, sid)
// Held while the session is open.
if lease, err := agent.TryAcquireSessionLease(path); err == nil {
lease.Release()
t.Fatalf("transcript lease not held after session/new")
}
resp := client.call(t, "session/close", SessionCloseParams{SessionID: sid})
if resp.Error != nil {
t.Fatalf("session/close errored: %+v", resp.Error)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("transcript lease not released by session/close: %v", err)
}
lease.Release()
}
+4
View File
@@ -25,6 +25,9 @@ func TestDeleteSessionFilesSweepsEventLogAndSidecars(t *testing.T) {
if err := os.WriteFile(acpMetaPath(path), []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write acp meta: %v", err)
}
if err := os.WriteFile(store.SessionConflictLog(path), []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write conflict log: %v", err)
}
if err := deleteSessionFiles(path); err != nil {
t.Fatalf("deleteSessionFiles: %v", err)
@@ -35,6 +38,7 @@ func TestDeleteSessionFilesSweepsEventLogAndSidecars(t *testing.T) {
store.SessionMeta(path),
store.SessionEventLog(path),
store.SessionEventIndex(path),
store.SessionConflictLog(path),
} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("artifact survived delete: %s (err=%v)", p, err)
+349
View File
@@ -0,0 +1,349 @@
package acp
import (
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// divergedACPSession writes a transcript to path whose on-disk content has
// diverged from the returned in-memory session, so the next Snapshot on a
// controller holding the stale session hits a conflict and retargets to a
// recovery branch.
func divergedACPSession(t *testing.T, path string) *agent.Session {
t.Helper()
disk := agent.NewSession("sys prompt")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := disk.Save(path); err != nil {
t.Fatalf("save disk session: %v", err)
}
stale := agent.NewSession("sys prompt")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
return stale
}
// primaryRecoveryFiles filters a recovery-branch glob down to primary session
// transcripts, dropping the .events.jsonl / .guardian.jsonl sidecars that the
// *-recovery-*.jsonl pattern also matches.
func primaryRecoveryFiles(t *testing.T, dir string) []string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
primary := matches[:0]
for _, path := range matches {
base := filepath.Base(path)
if strings.HasSuffix(base, ".events.jsonl") || strings.HasSuffix(base, ".guardian.jsonl") {
continue
}
primary = append(primary, path)
}
return primary
}
func assertACPSessionOnRecoveryPath(t *testing.T, sess *acpSession, originalPath, recoveryPath string) {
t.Helper()
if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
}
sess.mu.Lock()
transcript := sess.transcript
lease := sess.lease
sess.mu.Unlock()
if transcript != recoveryPath {
t.Fatalf("session transcript = %q, want recovery path %q", transcript, recoveryPath)
}
if lease == nil || lease.Path() != agent.CanonicalSessionPath(recoveryPath) {
got := ""
if lease != nil {
got = lease.Path()
}
t.Fatalf("session lease path = %q, want recovery path %q", got, recoveryPath)
}
// The original transcript's lease must have been released by the move so
// another runtime can bind it.
orig, err := agent.TryAcquireSessionLease(originalPath)
if err != nil {
t.Fatalf("original transcript lease should be free after recovery move: %v", err)
}
orig.Release()
}
// TestACPRebuildSessionContinuesRecoveryPathAfterSnapshotConflict is the ACP
// twin of the desktop rebuild fix: when the pre-rebuild Snapshot hits a
// conflict and retargets the old controller to a recovery branch, the session
// bookkeeping must follow at commit time (sessionRecoveredHandler moves
// sess.transcript and the lease), and AdoptHistory must bind the replacement
// controller to that recovery path. A pre-snapshot capture bound the
// just-recovered transcript back to the original file, so every later save
// re-conflicted and derived yet another recovery branch.
func TestACPRebuildSessionContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
dir := t.TempDir()
originalPath := filepath.Join(dir, "acp-switch-conflict.jsonl")
stale := divergedACPSession(t, originalPath)
sink := newUpdateSink(&fakeNotifier{}, "sess-recovery")
sess := &acpSession{
id: "sess-recovery",
sink: sink,
cwd: dir,
model: "fast",
transcript: originalPath,
}
lease, err := agent.TryAcquireSessionLease(originalPath)
if err != nil {
t.Fatalf("acquire original session lease: %v", err)
}
sess.lease = lease
t.Cleanup(sess.releaseSessionLease)
svc := &service{
factory: &configurableFactory{dir: dir},
sessions: map[string]*acpSession{sess.id: sess},
}
oldCtrl := control.New(control.Options{
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
SessionDir: dir,
SessionPath: originalPath,
Label: "fast",
OnSessionRecovered: svc.sessionRecoveredHandler(sess.id),
})
sess.ctrl = oldCtrl
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}); err != nil {
t.Fatalf("rebuildSession: %v", err)
}
if sess.ctrl == oldCtrl {
t.Fatal("session controller was not replaced")
}
recoveryPath := sess.ctrl.SessionPath()
assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
// The rebuilt controller adopted the recovery file's baseline, so its next
// snapshot must not derive a second recovery branch.
if err := sess.ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot after rebuild: %v", err)
}
if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", primary, recoveryPath)
}
}
// TestACPPersistAfterTurnMovesBookkeepingToRecoveryPath covers the autosave
// path: a turn-end Snapshot in persistAfterTurn that recovers onto a recovery
// branch must move sess.transcript and the session lease with the controller,
// so session/prompt reports the live file, session/delete destroys it, and the
// recovery transcript stays lease-guarded against other runtimes.
func TestACPPersistAfterTurnMovesBookkeepingToRecoveryPath(t *testing.T) {
dir := t.TempDir()
originalPath := filepath.Join(dir, "acp-autosave-conflict.jsonl")
stale := divergedACPSession(t, originalPath)
sink := newUpdateSink(&fakeNotifier{}, "sess-autosave")
sess := &acpSession{
id: "sess-autosave",
sink: sink,
cwd: dir,
model: "fast",
transcript: originalPath,
}
lease, err := agent.TryAcquireSessionLease(originalPath)
if err != nil {
t.Fatalf("acquire original session lease: %v", err)
}
sess.lease = lease
t.Cleanup(sess.releaseSessionLease)
svc := &service{
factory: &configurableFactory{dir: dir},
sessions: map[string]*acpSession{sess.id: sess},
}
ctrl := control.New(control.Options{
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
SessionDir: dir,
SessionPath: originalPath,
Label: "fast",
OnSessionRecovered: svc.sessionRecoveredHandler(sess.id),
})
sess.ctrl = ctrl
t.Cleanup(ctrl.Close)
sess.persistAfterTurn("hello")
recoveryPath := ctrl.SessionPath()
assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
t.Fatalf("recovery branches after autosave = %v, want only %q", primary, recoveryPath)
}
// The next turn-end autosave writes the recovery file the session now
// owns; it must not derive a second recovery branch.
sess.persistAfterTurn("again")
if got := ctrl.SessionPath(); got != recoveryPath {
t.Fatalf("controller session path after second autosave = %q, want %q", got, recoveryPath)
}
if primary := primaryRecoveryFiles(t, dir); len(primary) != 1 || primary[0] != recoveryPath {
t.Fatalf("recovery branches after second autosave = %v, want only %q", primary, recoveryPath)
}
}
// recoverACPSessionAndRestart drives an autosave recovery for session id in
// dir, then simulates a process restart: the live session's lease is released,
// its controller closed, and a fresh service (empty session registry, same
// session dir) is returned alongside the original and recovery paths.
func recoverACPSessionAndRestart(t *testing.T, dir, id string) (originalPath, recoveryPath string, restarted *service) {
t.Helper()
originalPath = transcriptPath(dir, id)
stale := divergedACPSession(t, originalPath)
svc := &service{
factory: &configurableFactory{dir: dir},
sessions: map[string]*acpSession{},
}
sess := &acpSession{
id: id,
sink: newUpdateSink(&fakeNotifier{}, id),
cwd: dir,
model: "fast",
title: "recovered title",
transcript: originalPath,
}
lease, err := agent.TryAcquireSessionLease(originalPath)
if err != nil {
t.Fatalf("acquire original session lease: %v", err)
}
sess.lease = lease
svc.sessions[id] = sess
ctrl := control.New(control.Options{
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
SessionDir: dir,
SessionPath: originalPath,
Label: "fast",
OnSessionRecovered: svc.sessionRecoveredHandler(id),
})
sess.ctrl = ctrl
sess.persistAfterTurn("hello")
recoveryPath = ctrl.SessionPath()
assertACPSessionOnRecoveryPath(t, sess, originalPath, recoveryPath)
sess.releaseSessionLease()
ctrl.Close()
restarted = &service{
conn: NewConn(strings.NewReader(""), io.Discard),
factory: &configurableFactory{dir: dir},
sessions: map[string]*acpSession{},
}
return originalPath, recoveryPath, restarted
}
// TestACPLoadAfterRestartFollowsRecoveryTranscript covers the restart half of
// the recovery move: session/load and session/resume resolve the session id to
// the transcript the session actually lives in. Without the id-keyed redirect,
// a restart reopened the pre-recovery file and the user's recovered work
// silently vanished from ACP's view.
func TestACPLoadAfterRestartFollowsRecoveryTranscript(t *testing.T) {
dir := t.TempDir()
id := "sess-restart"
originalPath, recoveryPath, svc := recoverACPSessionAndRestart(t, dir, id)
if _, err := svc.openExistingSession(context.Background(), "session/load", id, dir, nil, false); err != nil {
t.Fatalf("openExistingSession after restart: %v", err)
}
loaded := svc.session(id)
if loaded == nil {
t.Fatal("session not registered after load")
}
t.Cleanup(func() {
loaded.releaseSessionLease()
loaded.ctrl.Close()
})
assertACPSessionOnRecoveryPath(t, loaded, originalPath, recoveryPath)
if got := loaded.ctrl.SessionPath(); got != recoveryPath {
t.Fatalf("loaded controller session path = %q, want recovery path %q", got, recoveryPath)
}
// The test factory's controller has no executor, so prove the content via
// the transcript ACP now points at: it must hold the recovered local line,
// not the pre-recovery disk line.
resumed, err := agent.LoadSession(loaded.transcript)
if err != nil {
t.Fatalf("load resolved transcript: %v", err)
}
msgs := resumed.Snapshot()
if len(msgs) == 0 {
t.Fatal("resolved transcript is empty")
}
if got := msgs[len(msgs)-1].Content; got != "local second" {
t.Fatalf("resolved transcript last message = %q, want recovered local transcript (%q)", got, "local second")
}
}
// TestACPDeleteAfterRestartRemovesRecoveryAndIDKeyedFiles: session/delete on a
// non-live recovered session must remove both the recovery transcript (the
// session's live file) and the id-keyed original, or the survivor resurfaces
// in session/list as a ghost that can never be deleted by id.
func TestACPDeleteAfterRestartRemovesRecoveryAndIDKeyedFiles(t *testing.T) {
dir := t.TempDir()
id := "sess-del"
originalPath, recoveryPath, svc := recoverACPSessionAndRestart(t, dir, id)
raw, err := json.Marshal(SessionDeleteParams{SessionID: id})
if err != nil {
t.Fatalf("marshal delete params: %v", err)
}
if _, err := svc.sessionDelete(context.Background(), raw); err != nil {
t.Fatalf("sessionDelete after restart: %v", err)
}
for _, path := range []string{originalPath, recoveryPath, acpMetaPath(originalPath), acpMetaPath(recoveryPath)} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%s should be removed by session/delete, stat err = %v", path, err)
}
}
res, err := svc.sessionList(context.Background(), nil)
if err != nil {
t.Fatalf("sessionList after delete: %v", err)
}
if sessions := res.(SessionListResult).Sessions; len(sessions) != 0 {
t.Fatalf("session list after delete = %#v, want empty", sessions)
}
}
// TestACPSessionListAfterRecoveryShowsSingleActiveEntry: after a recovery the
// id-keyed sidecar becomes a redirect, and session/list must present exactly
// one entry for the id, backed by the active recovery transcript's metadata
// (the live title), never the stale pre-recovery sidecar.
func TestACPSessionListAfterRecoveryShowsSingleActiveEntry(t *testing.T) {
dir := t.TempDir()
id := "sess-list"
_, _, svc := recoverACPSessionAndRestart(t, dir, id)
res, err := svc.sessionList(context.Background(), nil)
if err != nil {
t.Fatalf("sessionList after recovery: %v", err)
}
sessions := res.(SessionListResult).Sessions
if len(sessions) != 1 {
t.Fatalf("session list after recovery = %#v, want exactly one entry", sessions)
}
if sessions[0].SessionID != id {
t.Fatalf("session list entry id = %q, want %q", sessions[0].SessionID, id)
}
if sessions[0].Title != "recovered title" {
t.Fatalf("session list entry title = %q, want the active transcript's title %q", sessions[0].Title, "recovered title")
}
}
+199 -48
View File
@@ -23,6 +23,7 @@ import (
"reasonix/internal/nilutil"
"reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/shellparse"
"reasonix/internal/tool"
)
@@ -273,6 +274,10 @@ type Agent struct {
// server's readOnlyHint for plan-mode execution without changing tool schemas.
planModeReadOnlyTrust PlanModeReadOnlyTrustGate
// sandboxEscapeApprover, when non-nil, can ask the user whether one shell
// command may rerun unconfined after the OS sandbox failed to start.
sandboxEscapeApprover sandbox.EscapeApprover
// hooks, when non-nil, fires PreToolUse / PostToolUse shell hooks around each
// tool call. nil disables hook firing.
hooks ToolHooks
@@ -380,18 +385,41 @@ type Agent struct {
compactStuck bool
consecutiveCompacts int
// stormSig / stormCount track a run of turns that keep failing the same way so
// the loop can break a death-spiral. The signature is each call's (tool, error)
// in order, NOT (tool, args): a stuck model reliably reworks the arguments
// cosmetically (a re-worded essay, a reordered object) while the call fails
// identically every time — keying on args misses the loop entirely (observed
// live against truncated tool-call arguments). Because errors that embed their
// subject (e.g. "file not found: /x") differ per target, genuine varied probing
// does not collapse to one signature. Reset whenever a turn does anything else
// (a different failure shape, or any success). See applyStormBreaker.
// stormSig / stormCount track a run of turns that keep failing or getting
// blocked the same way so the loop can break a death-spiral. The signature is
// each call's (tool, error/blocker) in order, NOT (tool, args): a stuck model
// reliably reworks the arguments cosmetically (a re-worded essay, a reordered
// object, a different shell command) while the host returns the same refusal or
// failure every time — keying on args misses the loop entirely. Because errors
// that embed their subject (e.g. "file not found: /x") differ per target,
// genuine varied probing does not collapse to one signature. Reset whenever a
// turn does anything else (a different failure/block shape, or any success).
// See applyStormBreaker.
stormSig string
stormCount int
// blockedTurnStreak counts consecutive turns in which every tool call was
// blocked by the host (permission, plan mode, hook, or loop guard).
// stormSig catches a model fixated on one call shape; this catches a model
// rotating between blocked shapes — alternating tools, reordering a batch,
// or blockers whose text varies per attempt — which is zero progress all
// the same. Reset by any turn containing a non-blocked outcome and at the
// start of each user turn. See applyStormBreaker.
blockedTurnStreak int
// loopGuardArmed / loopGuardReceiptMark let final readiness stand down
// after a loop guard fired this user turn: once the host has told the model
// to stop retrying and report the blocker, demanding the receipts that the
// blocker prevents would restart the loop the guard just broke. The mark is
// the evidence-ledger receipt count from just before the guarded batch, so
// real progress — a successful write or command receipt landing after it —
// revokes the pass, while the bookkeeping the guard itself recommends
// (ask, todo_write, complete_step) keeps it. Host state, not message text:
// tool output that merely quotes "[loop guard]" must not unlock readiness.
// Reset at the start of each user turn. See loopGuardAllowsFinal.
loopGuardArmed bool
loopGuardReceiptMark int
// repeatSuccessCounts tracks write-like tool calls that have already
// succeeded in this user turn. This catches the complementary loop shape to
// stormSig: a model keeps doing the same successful write, so there is no
@@ -587,6 +615,15 @@ func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) {
a.planModeReadOnlyTrust = g
}
// SetSandboxEscapeApprover installs the optional one-shot approval path used by
// the bash tool when an enforced OS sandbox fails to start.
func (a *Agent) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
if nilutil.IsNil(g) {
g = nil
}
a.sandboxEscapeApprover = g
}
func (a *Agent) withTurnPreferences(input string) string {
if a == nil {
return input
@@ -760,6 +797,10 @@ type Options struct {
// plan mode would otherwise block them. nil keeps fail-closed behavior.
PlanModeReadOnlyTrustGate PlanModeReadOnlyTrustGate
// SandboxEscapeApprover confirms a one-shot unconfined shell rerun after an
// enforced OS sandbox fails. nil keeps fail-closed behavior.
SandboxEscapeApprover sandbox.EscapeApprover
// Context management. ContextWindow <= 0 disables compaction. Ratios and
// RecentKeep fall back to defaults when unset.
ContextWindow int
@@ -848,6 +889,10 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
if nilutil.IsNil(planModeReadOnlyTrust) {
planModeReadOnlyTrust = nil
}
sandboxEscapeApprover := opts.SandboxEscapeApprover
if nilutil.IsNil(sandboxEscapeApprover) {
sandboxEscapeApprover = nil
}
hooks := opts.Hooks
if nilutil.IsNil(hooks) {
hooks = nil
@@ -878,6 +923,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
sink: sink,
gate: gate,
planModeReadOnlyTrust: planModeReadOnlyTrust,
sandboxEscapeApprover: sandboxEscapeApprover,
hooks: hooks,
jobs: opts.Jobs,
evidence: evidence.NewLedger(),
@@ -934,6 +980,9 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
a.evidence.Reset()
}
a.repeatSuccessCounts = nil
a.blockedTurnStreak = 0
a.loopGuardArmed = false
a.loopGuardReceiptMark = 0
a.sink.Emit(event.Event{Kind: event.TurnStarted})
rawInput := input
memoryCompilerInput := rawInput
@@ -1211,6 +1260,9 @@ func (a *Agent) finalReadinessCheck() finalReadinessCheck {
writer, hasWriter := a.evidence.LatestSuccessfulWriterIndex()
if !hasWriter {
if len(missing) > 0 {
if a.loopGuardAllowsFinal() {
return out
}
out.reason = strings.Join(missing, "; ")
}
return out
@@ -1235,10 +1287,41 @@ func (a *Agent) finalReadinessCheck() finalReadinessCheck {
if len(missing) == 0 {
return out
}
if a.loopGuardAllowsFinal() {
return out
}
out.reason = strings.Join(missing, "; ")
return out
}
// armLoopGuardPass records that a loop guard fired this user turn.
// receiptMark is the evidence-ledger receipt count from just before the
// guarded batch ran, so a successful write or command receipt recorded after
// it counts as real progress and revokes the pass (see loopGuardAllowsFinal).
func (a *Agent) armLoopGuardPass(receiptMark int) {
a.loopGuardArmed = true
a.loopGuardReceiptMark = receiptMark
}
// loopGuardAllowsFinal reports whether final readiness should stand down: a
// loop guard fired this user turn and no host-observable progress — a
// successful write or command receipt — has landed since. In that state the
// missing receipts are exactly what the blocker prevents, so demanding them
// would restart the retry loop the guard just broke; the model must be free to
// report the blocker instead. The bookkeeping the guard recommends (ask,
// todo_write, complete_step) produces neither write nor command receipts, so
// it keeps the pass; real progress revokes it because receipts are obtainable
// again and readiness should resume enforcing them.
func (a *Agent) loopGuardAllowsFinal() bool {
if a == nil || !a.loopGuardArmed {
return false
}
if a.evidence == nil {
return true
}
return !a.evidence.HasWriteOrCommandSince(a.loopGuardReceiptMark)
}
func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
parts := make([]string, 0, len(items))
for _, item := range items {
@@ -1446,7 +1529,7 @@ func finalReadinessCheckSource(check instruction.VerifyCheck) string {
}
func finalReadinessRetryMessage(reason string) string {
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run the required tool calls, then answer when readiness is satisfied. If the blocked item needs user input, a user-owned choice, or manual review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so."
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run only the required tool calls, then answer when readiness is satisfied. Prefer signing off completed work with complete_step and updating todo_write from existing receipts; do not run exploratory bash commands just to satisfy readiness. If a permission, plan-mode, hook, or loop-guard block prevents the required receipt, do not keep retrying the blocked command with different wording. If the blocked item needs user input, a user-owned choice, or manual review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so."
}
func shouldNudgeExecutorHandoff(input, answer string) bool {
@@ -1782,6 +1865,13 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []s
results := make([]string, len(calls))
outcomes := make([]toolOutcome, len(calls))
durations := make([]int64, len(calls))
// Snapshot the receipt count before the batch runs: if a loop guard fires
// for this batch, successes recorded during it (a mixed batch where only one
// call was guard-blocked) must already count as progress against the pass.
receiptMark := 0
if a.evidence != nil {
receiptMark = a.evidence.Len()
}
run := func(i int) {
start := time.Now()
outcomes[i] = a.executeOne(ctx, calls[i])
@@ -1877,7 +1967,7 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []s
a.compilerTurn.RecordToolResults(records)
}
if !cancelled {
a.applyStormBreaker(calls, outcomes, results)
a.applyStormBreaker(calls, outcomes, results, receiptMark)
}
return results
}
@@ -1989,58 +2079,116 @@ const stormBreakThreshold = 3
// no-op/write loop and should be redirected to a different tool or final answer.
const repeatSuccessBreakThreshold = 2
// applyStormBreaker detects a run of identically-failing turns and, past the
// loopGuardBlockErrMsg is the errMsg carried by a repeat-success loop-guard
// block. applyStormBreaker matches it to arm the final-readiness loop-guard
// pass, since that guard also invites the model to report the blocker.
const loopGuardBlockErrMsg = "blocked by loop guard"
// applyStormBreaker detects a run of zero-progress turns and, past the
// threshold, rewrites the model-facing result (results[0]) into a directive to
// change approach. It keys on each call's (tool, error) — not its args — because a
// stuck model reworks the arguments cosmetically while failing identically (see
// the stormSig field doc). A turn is a fixation candidate only when every one of
// its calls errored and none was merely blocked by plan mode / permissions (those
// carry a clear, distinct message the model can already act on). Any success, any
// block, or a different batch shape is varied work, so it resets the counter. This
// covers both the single-call spiral and a repeated multi-call batch. The hard
// maxSteps guard remains the ultimate backstop; this just keeps the loop from
// burning that whole budget bouncing off the same failure.
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string) {
// change approach. Two detectors, because a stuck model varies its retries two
// ways. The signature detector keys on each call's (tool, error/blocker) — not
// its args — since a stuck model reworks the arguments cosmetically while
// hitting the same host refusal or failure (see the stormSig field doc). The
// streak detector counts consecutive turns in which every call was blocked,
// regardless of shape: rotating tools, reordering a batch, or a blocker whose
// text varies per attempt escapes the signature but is still zero progress —
// only a host refusal (not a plain error) proves that, so the streak requires
// blocked outcomes. Any success resets both. When a guard fires — or when a
// call in the batch was already blocked by the per-call repeat-success guard —
// the final-readiness loop-guard pass is armed so the model may report the
// blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
// ultimate backstop; this just keeps the loop from burning that whole budget
// bouncing off the same host refusals.
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
allBlocked := len(outcomes) > 0
for _, outcome := range outcomes {
if !outcome.blocked {
allBlocked = false
break
}
}
if allBlocked {
a.blockedTurnStreak++
} else {
a.blockedTurnStreak = 0
}
for _, outcome := range outcomes {
if outcome.blocked && outcome.errMsg == loopGuardBlockErrMsg {
a.armLoopGuardPass(receiptMark)
break
}
}
sig, ok := batchStormSignature(calls, outcomes)
if !ok {
switch {
case !ok:
a.stormSig, a.stormCount = "", 0
return
}
if sig != a.stormSig {
case sig != a.stormSig:
a.stormSig, a.stormCount = sig, 1
default:
a.stormCount++
}
stormHit := ok && a.stormCount >= stormBreakThreshold
streakHit := allBlocked && a.blockedTurnStreak >= stormBreakThreshold
if !stormHit && !streakHit {
return
}
a.stormCount++
if a.stormCount < stormBreakThreshold {
return
const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
var guard, notice string
if stormHit {
subject := fmt.Sprintf("%q", calls[0].Name)
short := calls[0].Name
if len(calls) > 1 {
subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
short = fmt.Sprintf("a batch of %d calls", len(calls))
}
anyBlocked := false
for _, outcome := range outcomes {
if outcome.blocked {
anyBlocked = true
break
}
}
action := "failed"
advice := "Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer."
if anyBlocked {
action = "been blocked or failed"
advice = blockedAdvice
}
guard = fmt.Sprintf(
"[loop guard] %s has now %s %d times in a row with the same host response. Re-sending it — even with the wording changed — will not help: the calls keep hitting the same outcome. %s",
subject, action, a.stormCount, advice)
notice = fmt.Sprintf(
"loop guard: %s hit the same host response %d× — nudging the model to change approach",
short, a.stormCount)
} else {
guard = fmt.Sprintf(
"[loop guard] every tool call in the last %d turns has been blocked by the host (permission, plan mode, hook, or loop guard). Switching tools, reordering calls, or rewording arguments will not help while the blockers stand. %s",
a.blockedTurnStreak, blockedAdvice)
notice = fmt.Sprintf(
"loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
a.blockedTurnStreak)
}
subject := fmt.Sprintf("%q", calls[0].Name)
short := calls[0].Name
if len(calls) > 1 {
subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
short = fmt.Sprintf("a batch of %d calls", len(calls))
}
results[0] = outcomes[0].output + fmt.Sprintf(
"\n\n[loop guard] %s has now failed %d times in a row with the same error. Re-sending it — even with the wording changed — will not help: the calls keep failing the same way. Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer.",
subject, a.stormCount)
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(
"loop guard: %s failed %d× the same way — nudging the model to change approach",
short, a.stormCount)})
results[0] = outcomes[0].output + "\n\n" + guard
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: notice})
a.armLoopGuardPass(receiptMark)
}
// batchStormSignature returns a per-turn fixation signature — each call's
// (name, error) in order — and ok=true only when every call errored and none was
// merely blocked. ok=false (any success or block) means the turn made varied
// progress, so the caller resets the counter. Keying on the error rather than the
// args is deliberate: a stuck model reworks the arguments while failing the same
// way, so identical-args matching would miss the loop.
// (name, error/blocker) in order — and ok=true only when every call errored or
// was blocked. ok=false (any success) means the turn made progress, so the
// caller resets the counter. Keying on the host response rather than the args is
// deliberate: a stuck model reworks the arguments while hitting the same
// response, so identical-args matching would miss the loop.
func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (string, bool) {
if len(calls) == 0 {
return "", false
}
var sb strings.Builder
for i := range calls {
if outcomes[i].errMsg == "" || outcomes[i].blocked {
if outcomes[i].errMsg == "" {
return "", false
}
sb.WriteString(calls[i].Name)
@@ -2080,7 +2228,7 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
return toolOutcome{
output: out,
blocked: true,
errMsg: "blocked by loop guard",
errMsg: loopGuardBlockErrMsg,
}
}
if out, blocked := a.staleAnchorEditBlock(call); blocked {
@@ -2190,6 +2338,9 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
if a.jobs != nil {
cctx = jobs.WithManager(cctx, a.jobs)
}
if a.sandboxEscapeApprover != nil {
cctx = sandbox.WithEscapeApprover(cctx, a.sandboxEscapeApprover)
}
if v := a.responseLanguage.Load(); v != nil {
if lang, ok := v.(string); ok {
cctx = WithResponseLanguagePreference(cctx, lang)
+40 -3
View File
@@ -37,9 +37,15 @@ type BranchMeta struct {
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recovery_reason,omitempty"`
RecoveryDigest string `json:"recovery_digest,omitempty"`
Revision int64 `json:"revision,omitempty"`
ContentDigest string `json:"content_digest,omitempty"`
WriterID string `json:"writer_id,omitempty"`
// RecoveryDepth counts how many recovery forks separate this branch from a
// normal session (1 = forked from a normal session). SaveRecoveryBranch
// refuses to fork past SessionRecoveryMaxDepth so a conflict loop cannot
// spawn unbounded nested recovery chains (#5993 reached 8 levels). Legacy
// recovery metas without the field are treated as depth 1.
RecoveryDepth int `json:"recovery_depth,omitempty"`
Revision int64 `json:"revision,omitempty"`
ContentDigest string `json:"content_digest,omitempty"`
WriterID string `json:"writer_id,omitempty"`
// SchemaVersion records the BranchMeta version that last wrote the listing
// fields (Turns/Preview) FROM the session's content. It is stamped only by the
// writers that actually derive those counts — Controller.snapshot's
@@ -131,6 +137,32 @@ func LoadBranchMeta(sessionPath string) (BranchMeta, bool, error) {
return m, true, nil
}
// branchMetaReadBackoffs paces the re-reads of a branch-meta sidecar that
// failed to load. On Windows fileutil.ReplaceFile can fall back to a
// non-atomic in-place copy, so a concurrent reader may catch the sidecar
// half-written (an open/read error or truncated JSON). Those tears heal in
// milliseconds; a few short retries separate them from real corruption.
var branchMetaReadBackoffs = []time.Duration{20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond}
// loadBranchMetaRetry reads the branch-meta sidecar like LoadBranchMeta but
// retries transient failures (I/O errors and undecodable JSON) before giving
// up. A missing sidecar is a legitimate state — a session that has never
// recorded meta — and returns ok=false immediately without retrying.
func loadBranchMetaRetry(sessionPath string) (BranchMeta, bool, error) {
var lastErr error
for attempt := 0; ; attempt++ {
meta, ok, err := LoadBranchMeta(sessionPath)
if err == nil {
return meta, ok, nil
}
lastErr = err
if attempt >= len(branchMetaReadBackoffs) {
return BranchMeta{}, false, lastErr
}
time.Sleep(branchMetaReadBackoffs[attempt])
}
}
func SaveBranchMeta(sessionPath string, m BranchMeta) error {
return saveBranchMeta(sessionPath, m, true)
}
@@ -335,6 +367,11 @@ func RenameSession(sessionPath string, title string) error {
if sessionPath == "" {
return fmt.Errorf("empty session path")
}
// Read-modify-write on the sidecar: hold the per-path meta lock so a
// concurrent save (recordSessionContentRevision) can't have its Revision
// bump clobbered by a stale read-back here.
unlock := lockSessionSavePath(sessionPath)
defer unlock()
m, err := EnsureBranchMeta(sessionPath)
if err != nil {
return err
+39
View File
@@ -0,0 +1,39 @@
package agent
import (
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestCanonicalSessionPathMatchesLeaseRegistryKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "Mixed-Case", "20260705-Test.jsonl")
if got, want := CanonicalSessionPath(path), canonicalSessionSavePath(path); got != want {
t.Fatalf("CanonicalSessionPath(%q) = %q, want lease key %q", path, got, want)
}
}
func TestCanonicalSessionPathIdempotentAndEmptySafe(t *testing.T) {
if got := CanonicalSessionPath(""); got != "" {
t.Fatalf("empty path resolved to %q; must stay empty", got)
}
if got := CanonicalSessionPath(" "); got != "" {
t.Fatalf("blank path resolved to %q; must stay empty", got)
}
path := filepath.Join(t.TempDir(), "A", "b.jsonl")
key := CanonicalSessionPath(path)
if again := CanonicalSessionPath(key); again != key {
t.Fatalf("not idempotent: %q -> %q", key, again)
}
}
func TestCanonicalSessionPathFoldsCaseOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case folding is Windows-only")
}
path := filepath.Join(t.TempDir(), "Sessions", "20260705-Test.jsonl")
if CanonicalSessionPath(path) != CanonicalSessionPath(strings.ToUpper(path)) {
t.Fatal("case variants of one file produced distinct keys")
}
}
+15
View File
@@ -8,6 +8,7 @@ import (
"reasonix/internal/event"
"reasonix/internal/nilutil"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/tool"
)
@@ -183,6 +184,20 @@ func (c *Coordinator) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate)
}
}
// SetSandboxEscapeApprover propagates one-shot shell sandbox escape approvals to
// both tool-using agents in two-model mode.
func (c *Coordinator) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
if c == nil {
return
}
if c.plannerAgent != nil {
c.plannerAgent.SetSandboxEscapeApprover(g)
}
if c.executor != nil {
c.executor.SetSandboxEscapeApprover(g)
}
}
// Run plans with the planner model, then hands the plan to the executor.
func (c *Coordinator) Run(ctx context.Context, input string) error {
c.sink.Emit(event.Event{Kind: event.TurnStarted})
+106
View File
@@ -412,6 +412,112 @@ func TestFinalReadinessStopsAfterRepeatedBlocks(t *testing.T) {
}
}
func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
t.Fatal("todo_write builtin not registered")
}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(todoWrite)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("w1", "write_file", `{"path":"changed.go","content":"package main"}`),
toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "premature final"}, {Type: provider.ChunkDone}},
{toolCallChunk("b1", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}},
{toolCallChunk("b2", "bash", `{"command":"git status --short"}`), {Type: provider.ChunkDone}},
{toolCallChunk("b3", "bash", `{"command":"ls -la"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
}}
sink, notices := warnNoticeRecorder()
a := New(prov, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, sink)
if err := a.Run(context.Background(), "edit with todo, then hit bash permission blocks"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 6 {
t.Fatalf("provider calls = %d, want readiness retry, three blocked bash calls, then final", prov.call)
}
if !sessionHasUserMessageContaining(a.session, "final-answer readiness") {
t.Fatal("missing synthetic readiness retry message")
}
if got := lastToolResult(a.session, "bash"); !strings.Contains(got, "[loop guard]") {
t.Fatalf("last bash result = %q, want permission loop guard", got)
}
if got := toolResults(a.session, "bash"); len(got) != stormBreakThreshold {
t.Fatalf("bash results = %d, want exactly %d blocked attempts", len(got), stormBreakThreshold)
}
if len(*notices) == 0 {
t.Fatal("loop guard should emit a user-facing warning notice")
}
}
// TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch pins the
// multi-call variant: the guard text lands on the batch's FIRST result, so any
// detection keyed to the latest tool message misses it. The loop-guard pass is
// host state and must let the model report the blocker regardless of where in
// the batch the guard text sits.
func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
t.Fatal("todo_write builtin not registered")
}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(todoWrite)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("w1", "write_file", `{"path":"changed.go","content":"package main"}`),
toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "premature final"}, {Type: provider.ChunkDone}},
{
toolCallChunk("b1a", "bash", `{"command":"go test ./..."}`),
toolCallChunk("b1b", "bash", `{"command":"go vet ./..."}`),
{Type: provider.ChunkDone},
},
{
toolCallChunk("b2a", "bash", `{"command":"git status --short"}`),
toolCallChunk("b2b", "bash", `{"command":"git diff --stat"}`),
{Type: provider.ChunkDone},
},
{
toolCallChunk("b3a", "bash", `{"command":"ls -la"}`),
toolCallChunk("b3b", "bash", `{"command":"pwd"}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, event.Discard)
if err := a.Run(context.Background(), "edit with todo, then hit batched bash permission blocks"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 6 {
t.Fatalf("provider calls = %d, want readiness retry, three blocked batches, then final", prov.call)
}
results := toolResults(a.session, "bash")
if len(results) != 2*stormBreakThreshold {
t.Fatalf("bash results = %d, want %d blocked attempts across three batches", len(results), 2*stormBreakThreshold)
}
if !strings.Contains(results[len(results)-2], "[loop guard]") {
t.Fatalf("first result of the guarded batch should carry the loop guard, got: %q", results[len(results)-2])
}
if strings.Contains(results[len(results)-1], "[loop guard]") {
t.Fatalf("last result of the guarded batch should stay untouched (the pass must not depend on it), got: %q", results[len(results)-1])
}
}
func TestTodoWriteOnlyTurnMayEndWithIncompleteTodos(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
+72
View File
@@ -6,6 +6,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
"reasonix/internal/provider"
)
func readinessLedger(receipts ...evidence.Receipt) *evidence.Ledger {
@@ -98,6 +99,75 @@ func TestFinalReadinessCheckAuditsIncompleteTodos(t *testing.T) {
}
}
func TestFinalReadinessAllowsFinalAfterLoopGuardedToolBlocker(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
ledger := readinessLedger(writer, todo)
a := &Agent{evidence: ledger}
a.armLoopGuardPass(ledger.Len())
got := a.finalReadinessCheck()
if !got.applies {
t.Fatalf("finalReadinessCheck() applies = false, want true audit after loop guard")
}
if got.reason != "" {
t.Fatalf("finalReadinessCheck() reason = %q, want loop guard to allow final blocker report", got.reason)
}
}
// TestFinalReadinessLoopGuardPassSurvivesBookkeeping proves the exact actions
// the loop guard recommends — ask, todo_write, complete_step — do not revoke
// the pass: the model must be able to record the blocker and then report it.
func TestFinalReadinessLoopGuardPassSurvivesBookkeeping(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
ledger := readinessLedger(writer, todo)
a := &Agent{evidence: ledger}
a.armLoopGuardPass(ledger.Len())
ledger.Record(evidence.Receipt{ToolName: "ask", Success: true})
ledger.Record(evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}})
ledger.Record(evidence.Receipt{ToolName: "complete_step", Success: true, Step: "edit"})
if got := a.finalReadinessCheck(); got.reason != "" {
t.Fatalf("finalReadinessCheck() reason = %q, want bookkeeping after the guard to keep the pass", got.reason)
}
}
// TestFinalReadinessLoopGuardPassRevokedByRealProgress proves a successful
// write or command receipt after the guard revokes the pass: receipts are
// obtainable again, so readiness resumes enforcing them.
func TestFinalReadinessLoopGuardPassRevokedByRealProgress(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
ledger := readinessLedger(writer, todo)
a := &Agent{evidence: ledger}
a.armLoopGuardPass(ledger.Len())
ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
if got := a.finalReadinessCheck(); got.reason == "" {
t.Fatal("finalReadinessCheck() reason empty, want real progress after the guard to revoke the pass")
}
}
// TestFinalReadinessIgnoresLoopGuardQuotedInToolOutput proves the pass is host
// state, not message text: a tool result that merely quotes "[loop guard]"
// (a grep over this repo, a pasted transcript) must not unlock readiness.
func TestFinalReadinessIgnoresLoopGuardQuotedInToolOutput(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
sess := NewSession("")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "edit"})
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "b1", Name: "bash"}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "b1", Name: "bash", Content: "agent.go:2082: \"[loop guard] %s has now %s %d times\""})
a := &Agent{evidence: readinessLedger(writer, todo), session: sess}
if got := a.finalReadinessCheck(); got.reason == "" {
t.Fatal("finalReadinessCheck() reason empty, want quoted loop-guard text to be ignored")
}
}
func TestFinalReadinessRetryMessageKeepsUserChoicesInteractive(t *testing.T) {
msg := finalReadinessRetryMessage("latest successful todo_write still has incomplete items: Ask user to review the doc: in_progress")
lower := strings.ToLower(msg)
@@ -106,6 +176,8 @@ func TestFinalReadinessRetryMessageKeepsUserChoicesInteractive(t *testing.T) {
"wait for its tool result",
"do not ask in prose",
"do not claim the user answered",
"do not run exploratory bash commands",
"do not keep retrying the blocked command",
} {
if !strings.Contains(lower, want) {
t.Fatalf("finalReadinessRetryMessage() missing %q:\n%s", want, msg)
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"bytes"
"os"
"path/filepath"
"strings"
"time"
)
// Recovery-branch garbage collection. Conflict recovery forks a copy of the
// in-memory transcript whenever a save conflicts (#5993); the triggers are
// fixed, but every fork that ever happened still sits in the session list
// until the user trashes it by hand. Most of them preserve nothing: the
// original session went on to contain everything the fork saved. Those — and
// only those — are safe to reclaim automatically.
// RecoveryGCGracePeriod is how long a reclaimable recovery branch must sit
// idle before GC may collect it. A fresh fork is part of an active conflict
// flow — the user may be comparing it against the original right now.
const RecoveryGCGracePeriod = 24 * time.Hour
// SessionLeaseHeld reports whether ANY live runtime — this process included —
// holds the session's write lease. SessionLeaseHeldByOtherRuntime deliberately
// answers false for the current process; GC needs the stricter question, since
// a branch open in one of our own tabs is just as much in use.
func SessionLeaseHeld(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
if _, ok := sessionLeaseOwners.Load(canonicalSessionSavePath(path)); ok {
return true
}
return SessionLeaseHeldByOtherRuntime(path)
}
// ReclaimableRecoveryBranches scans dir for conflict-recovery branches that
// are safe to dispose of. Every condition must hold — when in doubt the branch
// stays, because a recovery branch exists precisely to prevent data loss:
//
// 1. The branch meta says Recovered and records the fork digest.
// 2. The transcript still matches that fork digest: the branch was never
// continued on. A single follow-up turn disqualifies it permanently.
// 3. The parent transcript (meta.ParentID, same directory) exists and covers
// the branch content — equal digest, or the branch is a strict prefix
// (allowing a compatible leading-system swap). These are the same checks
// SaveRecoveryBranch uses to declare a recovery not needed in the first
// place, so "covered" here means the fork preserves nothing unique.
// 4. No live runtime holds the branch's session lease.
// 5. The branch has been idle for at least grace.
//
// It returns candidate paths only; disposal (trash, delete) is caller policy.
func ReclaimableRecoveryBranches(dir string, now time.Time, grace time.Duration) ([]string, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") || strings.HasSuffix(e.Name(), ".events.jsonl") {
continue
}
path := filepath.Join(dir, e.Name())
if !IsVisibleSession(path) {
continue
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
continue
}
if strings.TrimSpace(meta.ParentID) == "" {
continue
}
idleSince := meta.UpdatedAt
if idleSince.IsZero() {
info, err := e.Info()
if err != nil {
continue
}
idleSince = info.ModTime()
}
if now.Sub(idleSince) < grace {
continue
}
if SessionLeaseHeld(path) {
continue
}
branch, err := LoadSession(path)
if err != nil || branch == nil {
continue
}
branchMsgs := branch.Snapshot()
branchDigest, err := digestSessionMessages(branchMsgs)
if err != nil || digestString(branchDigest) != meta.RecoveryDigest {
// Continued on (or undigestable): this is someone's conversation now.
continue
}
parentPath := filepath.Join(dir, meta.ParentID+".jsonl")
if parentPath == path || !IsVisibleSession(parentPath) {
continue
}
parent, err := LoadSession(parentPath)
if err != nil || parent == nil {
continue
}
parentMsgs := parent.Snapshot()
parentDigest, err := digestSessionMessages(parentMsgs)
if err != nil {
continue
}
covered := bytes.Equal(parentDigest[:], branchDigest[:]) ||
messagesHavePrefix(parentMsgs, branchMsgs) ||
messagesHavePrefixWithCompatibleSystem(parentMsgs, branchMsgs)
if !covered {
continue
}
out = append(out, path)
}
return out, nil
}
+123
View File
@@ -0,0 +1,123 @@
package agent
import (
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/provider"
)
// forkRecoveryBranch builds a real conflict-recovery branch: a diverged disk
// parent plus a stale in-memory session, forked through SaveRecoveryBranch —
// the exact artifacts GC will meet in the field.
func forkRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string, branchMsgs []provider.Message) {
t.Helper()
parentPath = filepath.Join(dir, name+".jsonl")
disk := NewSession("sys")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
if err := disk.Save(parentPath); err != nil {
t.Fatalf("Save parent: %v", err)
}
stale := NewSession("sys")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: parentPath})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
return parentPath, info.Path, stale.Snapshot()
}
// coverBranchInParent rewrites the parent so it contains the branch's content
// plus later turns — the "original session went on and kept everything the
// fork preserved" shape that makes the fork redundant.
func coverBranchInParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
t.Helper()
merged := NewSession("")
merged.Messages = append([]provider.Message(nil), branchMsgs...)
merged.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
if err := merged.Save(parentPath); err != nil {
t.Fatalf("Save covering parent: %v", err)
}
}
func TestReclaimableRecoveryBranchesCollectsOnlyCoveredIdleForks(t *testing.T) {
dir := t.TempDir()
later := time.Now().Add(48 * time.Hour)
// Covered + idle + unleased: reclaimable.
_, covered, coveredMsgs := forkRecoveryBranch(t, dir, "covered")
coverBranchInParent(t, filepath.Join(dir, "covered.jsonl"), coveredMsgs)
// Diverged parent: the fork holds turns that exist nowhere else — kept.
forkRecoveryBranch(t, dir, "diverged")
// Continued on: one follow-up turn on the branch disqualifies it forever.
continuedParent, continuedBranch, continuedMsgs := forkRecoveryBranch(t, dir, "continued")
coverBranchInParent(t, continuedParent, continuedMsgs)
cont, err := LoadSession(continuedBranch)
if err != nil {
t.Fatalf("LoadSession continued branch: %v", err)
}
cont.Add(provider.Message{Role: provider.RoleAssistant, Content: "user kept chatting here"})
if err := cont.Save(continuedBranch); err != nil {
t.Fatalf("Save continued branch: %v", err)
}
got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod)
if err != nil {
t.Fatalf("ReclaimableRecoveryBranches: %v", err)
}
if len(got) != 1 || got[0] != covered {
t.Fatalf("reclaimable = %v, want only %q", got, covered)
}
}
func TestReclaimableRecoveryBranchesRespectsGraceLeaseAndMissingParent(t *testing.T) {
dir := t.TempDir()
later := time.Now().Add(48 * time.Hour)
parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "guarded")
coverBranchInParent(t, parentPath, branchMsgs)
// Fresh fork inside the grace window: kept.
if got, err := ReclaimableRecoveryBranches(dir, time.Now(), RecoveryGCGracePeriod); err != nil || len(got) != 0 {
t.Fatalf("within grace = %v err=%v, want none", got, err)
}
// Lease held (by this very process): kept.
lease, err := TryAcquireSessionLease(branchPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
if !SessionLeaseHeld(branchPath) {
t.Fatal("SessionLeaseHeld = false while this process holds the lease")
}
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
t.Fatalf("with lease held = %v err=%v, want none", got, err)
}
lease.Release()
if SessionLeaseHeld(branchPath) {
t.Fatal("SessionLeaseHeld = true after release")
}
// Released + idle: reclaimable now.
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 1 || got[0] != branchPath {
t.Fatalf("after release = %v err=%v, want %q", got, err, branchPath)
}
// Parent gone: content is no longer covered anywhere — kept.
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
if err := os.Remove(parentPath + suffix); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove parent artifact %s: %v", suffix, err)
}
}
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
t.Fatalf("parent missing = %v err=%v, want none", got, err)
}
}
+342 -52
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
@@ -44,16 +45,34 @@ var (
sessionSaveLocks sync.Map
ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
errSessionFileLockHeld = errors.New("session file lock held")
sessionWriterID = newSessionWriterID()
// ErrSessionRecoveryDepthExceeded refuses a recovery fork whose parent is
// already SessionRecoveryMaxDepth recovery forks deep. A chain that deep
// means saves keep conflicting on branches this runtime itself created;
// forking further multiplies session files without converging (#5993).
ErrSessionRecoveryDepthExceeded = errors.New("session recovery chain depth exceeded")
errSessionFileLockHeld = errors.New("session file lock held")
sessionWriterID = newSessionWriterID()
)
// SessionRecoveryMaxDepth bounds nested recovery forks: a normal session may
// fork a recovery branch (depth 1), which may itself fork twice more under
// genuine repeated incidents; past that the caller should stop forking and
// write onto the branch it already owns.
const SessionRecoveryMaxDepth = 3
type sessionPersistState struct {
path string
digest [sha256.Size]byte
version uint64
revision int64
ok bool
// revisionKnown marks revision as a real ledger value. It is false when
// the baseline was established while the meta sidecar was unreadable
// (torn or corrupt): the session must still open, but revision 0 must not
// pose as a baseline or every honest on-disk revision would read as a
// stale-runtime conflict. CAS checks fall back to digest+version until a
// successful save re-learns the revision.
revisionKnown bool
ok bool
}
type sessionSaveMode int
@@ -73,6 +92,11 @@ type snapshotWriteDecision struct {
// a lost suffix, or nothing decodable): the safe write shape is a full
// rewrite that also compacts the log back to a healthy single event.
repairLog bool
// ledgerStale is set when the on-disk transcript already matches the
// snapshot but the meta ledger still describes older content — the
// aftermath of a save whose bytes landed and whose revision record then
// failed. The up-to-date path must heal the ledger instead of skipping it.
ledgerStale bool
}
type SessionSnapshotConflictKind string
@@ -160,11 +184,6 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
if path == "" {
return fmt.Errorf("empty session path")
}
msgs, version := s.snapshotWithVersion()
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
if err != nil {
return err
}
baseRevision := int64(0)
unlock := lockSessionSavePath(path)
defer unlock()
@@ -176,6 +195,16 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
return fmt.Errorf("lock session file: %w", err)
}
defer unlockFile()
// Capture the snapshot only while holding the save locks. Concurrent
// in-process savers (turn-end snapshot, periodic autosave, shutdown
// snapshot) that captured before locking could land out of order: the
// stalest capture written last would then read the newer transcript it
// lost the race to as a bogus stale-prefix conflict.
msgs, version, rewriteVersion := s.snapshotWithVersion()
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
if err != nil {
return err
}
probe, err := probeSessionEventLog(path)
if err != nil {
return err
@@ -202,7 +231,28 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
// other runtime resumed on this file and turning their next
// legitimate save into a stale-runtime conflict. Skip the write and
// adopt the current on-disk revision as this session's baseline.
s.markPersisted(path, digest, version, decision.revision)
if decision.ledgerStale {
// ...unless the ledger never learned about this transcript: a
// prior save landed its bytes and then failed to record the
// revision. Same-content retries are exactly the "later save"
// that failure deferred to, and skipping here would strand the
// ledger on the old digest forever. Record now, reproducing
// the state the interrupted save would have left.
revision, err := recordSessionContentRevision(path, digest, decision.revision)
if err != nil {
return err
}
if probe.native {
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
// See the append path below: index loss must not fail a
// save whose transcript and revision already landed.
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
return nil
}
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
return nil
}
if decision.appendOnly && probe.native {
@@ -232,9 +282,14 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
return err
}
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
return err
// The event index is only a listing accelerator; the transcript
// and its revision are already durable above. Failing the save
// here would skip markPersisted and leave the in-memory baseline
// behind the disk state it just wrote, misreading the next save
// as a stale-runtime conflict.
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
s.markPersisted(path, digest, version, revision)
s.markPersisted(path, digest, version, revision, rewriteVersion)
return nil
}
baseRevision = decision.revision
@@ -291,10 +346,12 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
}
if probe.native {
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
return err
// See the append path above: index loss must not fail a save whose
// transcript and revision already landed.
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
}
s.markPersisted(path, digest, version, revision)
s.markPersisted(path, digest, version, revision, rewriteVersion)
return nil
}
@@ -341,7 +398,7 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
}
return snapshotWriteDecision{}, err
}
currentRevision, _, err := sessionContentRevision(path)
currentRevision, currentLedgerDigest, err := sessionContentRevision(path)
if err != nil {
return snapshotWriteDecision{}, err
}
@@ -351,10 +408,53 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
if err != nil {
return snapshotWriteDecision{}, err
}
// raw is the transcript as stored, before load-time normalization repaired
// it; it equals existing when no repair ran. The prefix checks below must
// be able to fall back to it: a mid-turn snapshot legitimately cuts an
// assistant tool call from its still-running result, normalization then
// fabricates a placeholder answer on load, and the live session's real
// result collides with that placeholder — misreading a pure append as
// divergence (and forking a bogus recovery branch).
raw, rawDigest := existing, existingDigest
rawDiffers := current.normalizedDirty && len(current.rawMessages) > 0
if rawDiffers {
raw = current.rawMessages
if rawDigest, err = digestSessionMessages(raw); err != nil {
return snapshotWriteDecision{}, err
}
}
contentUnchanged := bytes.Equal(existingDigest[:], nextDigest[:])
exactAppend := messagesHavePrefix(next, existing)
if contentUnchanged || exactAppend || messagesHavePrefixWithCompatibleSystem(next, existing) {
if baseState.ok && currentRevision != baseState.revision && !contentUnchanged {
appendShaped := contentUnchanged || exactAppend || messagesHavePrefixWithCompatibleSystem(next, existing)
repairPending := current.normalizedDirty
if !appendShaped && rawDiffers {
rawUnchanged := bytes.Equal(rawDigest[:], nextDigest[:])
rawAppend := messagesHavePrefix(next, raw)
if rawUnchanged || rawAppend || messagesHavePrefixWithCompatibleSystem(next, raw) {
existing = raw
contentUnchanged = rawUnchanged
exactAppend = rawAppend
appendShaped = true
// The snapshot supersedes the repaired view — appending it lands
// the real tool results where the placeholders were fabricated —
// so no load-time repair is left to force a rewrite.
repairPending = false
}
}
if appendShaped {
// An unknown-revision baseline (meta sidecar unreadable at load) cannot
// vouch for revision equality; the digest/prefix checks above already
// vouch for the content, so only a known baseline arms the CAS check.
// Under an append-shaped write (at most a compatible leading-system
// swap) a stale revision is ledger drift — a reset sidecar, a
// same-content heal, or another runtime recording messages this
// snapshot already contains — unless the transcript was rewound.
// Locating the persisted baseline among the snapshot's prefixes and
// requiring the disk transcript to still reach it tells the two apart:
// drift keeps the baseline reachable, while a rewind cut below it and
// appending would resurrect the suffix another runtime removed.
if baseState.ok && baseState.revisionKnown && currentRevision != baseState.revision && !contentUnchanged &&
!appendCoversPersistedBaseline(next, existing, baseState.digest) {
return snapshotWriteDecision{}, snapshotConflict(path, existing, next, baseState.revision, currentRevision)
}
// A normalized-dirty load means LoadSession repaired the history on the
@@ -364,19 +464,43 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
// replayable prefix already matches this snapshot.
decision := snapshotWriteDecision{
revision: currentRevision,
upToDate: contentUnchanged && !current.normalizedDirty && !current.eventLogDamaged,
upToDate: contentUnchanged && !repairPending && !current.eventLogDamaged,
repairLog: current.eventLogDamaged,
}
if exactAppend && !contentUnchanged && len(existing) < len(next) && !current.eventLogDamaged {
// A ledger digest that describes different content than the transcript
// on disk is the aftermath of a save whose bytes landed and whose
// revision record then failed (crash or fail-closed record between the
// two writes). Only a non-empty mismatch counts: a missing sidecar or
// a legacy one without a digest is a legitimate state, and stamping it
// here would bump revisions other runtimes still hold as baselines.
if decision.upToDate && currentLedgerDigest != "" && currentLedgerDigest != digestString(nextDigest) {
decision.ledgerStale = true
}
// An append is only chain-safe when existing measures the transcript
// the event log actually replays. Under a pending load-time repair the
// normalized view differs from the raw log, so an append event indexed
// against it breaks the replay chain and orphans the appended suffix;
// fall through to the full rewrite, which also persists the repair.
if exactAppend && !contentUnchanged && len(existing) < len(next) && !current.eventLogDamaged && !repairPending {
decision.appendOnly = true
decision.appendFrom = len(existing)
}
return decision, nil
}
if allowOwnedRewrite && s.ownsPersistedState(path, existingDigest, currentRevision, nextVersion) {
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
if allowOwnedRewrite {
owned := s.ownsPersistedState(path, existingDigest, currentRevision, currentLedgerDigest, nextVersion)
if !owned && rawDiffers {
// The persisted baseline describes the bytes this session wrote, so
// a repaired view can never match it; ownership is judged against
// the raw transcript.
owned = s.ownsPersistedState(path, rawDigest, currentRevision, currentLedgerDigest, nextVersion)
}
if owned {
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
}
}
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
Path: path,
Kind: SessionSnapshotConflictStalePrefix,
@@ -416,7 +540,7 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
if originalPath == "" {
return RecoveryBranchInfo{}, fmt.Errorf("empty original session path")
}
msgs, version := s.snapshotWithVersion()
msgs, version, rewriteVersion := s.snapshotWithVersion()
preview, turns := SessionPreviewFromMessages(msgs)
if turns == 0 {
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
@@ -445,13 +569,45 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
if digestErr != nil {
return RecoveryBranchInfo{}, digestErr
}
if bytes.Equal(existingDigest[:], digest[:]) ||
covered := bytes.Equal(existingDigest[:], digest[:]) ||
messagesHavePrefix(existing, msgs) ||
messagesHavePrefixWithCompatibleSystem(existing, msgs) {
messagesHavePrefixWithCompatibleSystem(existing, msgs)
if !covered && current.normalizedDirty && len(current.rawMessages) > 0 {
// Judge coverage against the pre-repair transcript too, for the
// same reason as checkSnapshotWrite: load-time normalization can
// reshape what is actually stored, and a recovery fork is only
// warranted when the stored bytes themselves fail to cover this
// snapshot.
raw := current.rawMessages
rawDigest, rawErr := digestSessionMessages(raw)
if rawErr != nil {
return RecoveryBranchInfo{}, rawErr
}
covered = bytes.Equal(rawDigest[:], digest[:]) ||
messagesHavePrefix(raw, msgs) ||
messagesHavePrefixWithCompatibleSystem(raw, msgs)
}
if covered {
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
}
}
// Refuse to deepen a runaway chain: forking FROM a branch that is already
// at the depth cap only multiplies recovery files (#5993 reached 8 nested
// levels). The caller falls back to force-writing the branch it owns.
parentDepth := 0
if parentMeta, ok, metaErr := LoadBranchMeta(originalPath); metaErr == nil && ok && parentMeta.Recovered {
parentDepth = parentMeta.RecoveryDepth
if parentDepth <= 0 {
// Legacy recovery meta predating RecoveryDepth.
parentDepth = 1
}
}
if parentDepth >= SessionRecoveryMaxDepth {
return RecoveryBranchInfo{}, fmt.Errorf("%w: %s is already %d recovery forks deep",
ErrSessionRecoveryDepthExceeded, originalPath, parentDepth)
}
recoveryPath := recoverySessionPath(originalPath, digest)
unlockRecovery := lockSessionSavePath(recoveryPath)
defer unlockRecovery()
@@ -466,11 +622,11 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
return RecoveryBranchInfo{}, digestErr
}
if bytes.Equal(existingDigest[:], digest[:]) {
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText)
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText, parentDepth+1)
if err != nil {
return RecoveryBranchInfo{}, err
}
s.markPersisted(recoveryPath, digest, version, meta.Revision)
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Existing: true, Meta: meta, Preview: preview, Turns: turns}, nil
}
} else if loadErr != nil && !os.IsNotExist(loadErr) {
@@ -495,18 +651,23 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
if err := writeSessionMessages(recoveryPath, msgs); err != nil {
return RecoveryBranchInfo{}, err
}
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText)
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText, parentDepth+1)
if err != nil {
return RecoveryBranchInfo{}, err
}
if err := writeSessionEventIndex(recoveryPath, msgs, digest, meta.Revision); err != nil {
return RecoveryBranchInfo{}, err
// The recovery transcript (log + checkpoint) and its meta are already
// durable; the index is only a listing accelerator. Failing here would
// discard a recovery that in fact succeeded and re-run the whole
// conflict path on the next save.
slog.Warn("session: keeping recovery branch after event index write failure",
"path", recoveryPath, "err", err)
}
s.markPersisted(recoveryPath, digest, version, meta.Revision)
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Meta: meta, Preview: preview, Turns: turns}, nil
}
func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions, preview string, turns int, digest string) (BranchMeta, error) {
func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions, preview string, turns int, digest string, depth int) (BranchMeta, error) {
meta := opts.BranchMeta
meta.ID = BranchID(path)
if strings.TrimSpace(meta.Name) == "" {
@@ -523,6 +684,9 @@ func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions
meta.Recovered = true
meta.RecoveryReason = firstNonEmpty(strings.TrimSpace(opts.Reason), "session snapshot conflict")
meta.RecoveryDigest = digest
// Always stamped from the parent chain, never trusted from opts: callers
// copy tab/session meta wholesale and would carry a stale depth.
meta.RecoveryDepth = depth
if meta.Revision == 0 {
meta.Revision = 1
}
@@ -605,12 +769,33 @@ func firstNonEmpty(values ...string) string {
return ""
}
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, nextVersion uint64) bool {
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool {
state := s.persistState(path)
return state.ok &&
state.version <= nextVersion &&
state.revision == existingRevision &&
bytes.Equal(existingDigest[:], state.digest[:])
if !state.ok || state.version > nextVersion || !bytes.Equal(existingDigest[:], state.digest[:]) {
return false
}
// An unknown-revision baseline still owns the transcript it loaded — the
// digest+version match proves it. Requiring revision equality here would
// make every rewrite from such a baseline a permanent conflict, because
// the revision can only be re-learned by a successful save.
// A disk ledger with no recorded revision is the mirror case: recorded
// revisions start at 1, so revision 0 means the sidecar was deleted or
// rebuilt by a listing-only writer after this session's save. An absent
// claim cannot revoke the ownership the digest+version match proves.
if !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision {
return true
}
// A foreign revision stamp whose recorded digest still describes these
// exact bytes (a same-content heal or no-op record by another runtime)
// vouches for no content of its own: the transcript is byte-for-byte what
// this session last persisted, so rewriting it destroys nothing of
// theirs — at worst the conflict moves to the stamper's next divergent
// save, where its in-memory history forks a recovery branch as usual.
// A stamp that disagrees with the on-disk transcript (or a legacy stamp
// with no digest) keeps revoking ownership: that is the aftermath of a
// save whose bytes and record split, the bytes cannot be attributed, and
// only the conservative conflict path preserves both sides.
return existingLedgerDigest == digestString(existingDigest)
}
func (s *Session) persistState(path string) sessionPersistState {
@@ -623,22 +808,46 @@ func (s *Session) persistState(path string) sessionPersistState {
return sessionPersistState{}
}
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64) {
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int) {
s.setPersistedBaseline(path, digest, version, revision, true, rewriteVersion)
}
// markPersistedRevisionUnknown records a baseline whose ledger revision could
// not be learned because the meta sidecar was unreadable. The digest and
// version still anchor ownership checks; revision-based CAS stays disarmed
// until a successful save records the real revision via markPersisted.
func (s *Session) markPersistedRevisionUnknown(path string, digest [sha256.Size]byte, version uint64, rewriteVersion int) {
s.setPersistedBaseline(path, digest, version, 0, false, rewriteVersion)
}
func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, version uint64, revision int64, revisionKnown bool, rewriteVersion int) {
s.mu.Lock()
defer s.mu.Unlock()
s.persisted = sessionPersistState{
path: canonicalSessionSavePath(path),
digest: digest,
version: version,
revision: revision,
ok: true,
path: canonicalSessionSavePath(path),
digest: digest,
version: version,
revision: revision,
revisionKnown: revisionKnown,
ok: true,
}
// rewriteVersion was captured together with the persisted snapshot; only
// move forward so a slower save that captured earlier cannot roll the
// baseline back below a rewrite a faster save already persisted.
if rewriteVersion > s.persistedRewriteVersion {
s.persistedRewriteVersion = rewriteVersion
}
}
// sessionContentRevision reads the CAS ledger (revision + content digest) from
// the branch-meta sidecar. A missing sidecar is revision 0 — a session that
// has never recorded one. An unreadable sidecar is an error: reporting it as
// revision 0 would desync every runtime baseline from the ledger and turn the
// next honest save into a bogus conflict (and a recovery branch).
func sessionContentRevision(path string) (int64, string, error) {
meta, ok, err := LoadBranchMeta(path)
meta, ok, err := loadBranchMetaRetry(path)
if err != nil {
return 0, "", nil
return 0, "", err
}
if !ok {
return 0, "", nil
@@ -647,9 +856,14 @@ func sessionContentRevision(path string) (int64, string, error) {
}
func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRevision int64) (int64, error) {
meta, ok, err := LoadBranchMeta(path)
meta, ok, err := loadBranchMetaRetry(path)
if err != nil {
return baseRevision, nil
// Fail the save instead of rebuilding the ledger from a bad read: the
// transcript bytes already landed, so a later save can record the
// revision once the sidecar reads cleanly again — a content-bearing
// save lands here again, and a same-content retry heals through the
// up-to-date ledgerStale path in save().
return 0, err
}
if !ok {
meta = BranchMeta{ID: BranchID(path)}
@@ -663,7 +877,7 @@ func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRev
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
return 0, err
}
stored, ok, err := LoadBranchMeta(path)
stored, ok, err := loadBranchMetaRetry(path)
if err != nil {
return 0, err
}
@@ -742,6 +956,49 @@ func messagesHavePrefix(full, prefix []provider.Message) bool {
return true
}
// messagesPrefixDigestDepth returns the number of leading messages of msgs
// whose storage digest equals target, or -1 when no prefix matches. The
// digest accumulates exactly like digestAndSizeSessionMessages, so a match at
// depth k means msgs[:k] is byte-for-byte the transcript that produced target.
func messagesPrefixDigestDepth(msgs []provider.Message, target [sha256.Size]byte) int {
h := sha256.New()
sum := make([]byte, 0, sha256.Size)
for i, m := range msgs {
b, err := json.Marshal(m)
if err != nil {
return -1
}
h.Write(b)
h.Write([]byte{'\n'})
sum = h.Sum(sum[:0])
if bytes.Equal(sum, target[:]) {
return i + 1
}
}
return -1
}
// appendCoversPersistedBaseline reports whether an append-shaped write (disk
// transcript a prefix of next, modulo a compatible leading-system swap) still
// covers everything this session ever persisted: the baseline digest must be
// reachable as a prefix of the pending snapshot, and the disk transcript must
// still extend at least to that depth. A shorter disk transcript means some
// other runtime deliberately rewound below the baseline — appending over it
// would resurrect the removed suffix, so the caller must conflict instead.
func appendCoversPersistedBaseline(next, existing []provider.Message, baseDigest [sha256.Size]byte) bool {
depth := messagesPrefixDigestDepth(next, baseDigest)
if depth < 0 && len(next) > 0 && len(existing) > 0 &&
next[0].Role == provider.RoleSystem && existing[0].Role == provider.RoleSystem &&
!messagesEqualForStorage(next[0], existing[0]) {
// A resume that swapped the system prompt persisted its baseline with
// the previous system message — the one still on disk. Re-anchor the
// search on that message so the swap alone doesn't hide the baseline.
variant := append([]provider.Message{existing[0]}, next[1:]...)
depth = messagesPrefixDigestDepth(variant, baseDigest)
}
return depth >= 0 && len(existing) >= depth
}
func messagesHavePrefixWithCompatibleSystem(full, prefix []provider.Message) bool {
full = messagesWithoutLeadingSystem(full)
prefix = messagesWithoutLeadingSystem(prefix)
@@ -813,6 +1070,20 @@ func canonicalSessionSavePath(path string) string {
return key
}
// CanonicalSessionPath is the identity key of a session path: cleaned,
// absolute, and case-folded on Windows, matching the key form used by the
// lease registry and the save-path locks. Any runtime bookkeeping that
// compares or maps session paths (desktop tabs, detached runtimes) must use
// this exact form, or the same file splits into distinct keys — e.g.
// `C:\Users\...` vs the lease's lowercased `c:\users\...`. Empty input stays
// empty instead of resolving to the working directory.
func CanonicalSessionPath(path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
return canonicalSessionSavePath(path)
}
// LoadSession reads a saved session into a fresh Session value. New sessions
// replay the append-only event log; legacy sessions without an event log fall
// back to the compatibility .jsonl checkpoint. A damaged log is replayed to its
@@ -838,14 +1109,30 @@ func LoadSession(path string) (*Session, error) {
normalized := NormalizeSession(s.Messages)
if len(normalized) != len(s.Messages) || (len(s.Messages) > 0 && &normalized[0] != &s.Messages[0]) {
s.normalizedDirty = true
// Keep the pre-repair transcript: checkSnapshotWrite must be able to
// recognize a snapshot that extends the bytes actually on disk, which
// the repaired view no longer represents (an interrupted tool turn
// gets a placeholder result fabricated here that the live session
// answered for real).
s.rawMessages = msgs
}
s.Messages = normalized
if digest, err := digestSessionMessages(s.Messages); err == nil {
revision := int64(0)
if meta, ok, metaErr := LoadBranchMeta(path); metaErr == nil && ok {
revision = meta.Revision
if meta, ok, metaErr := loadBranchMetaRetry(path); metaErr != nil {
// The sidecar exists but is unreadable even after retries (torn or
// corrupt). The session must still open, but revision 0 must not
// pose as a real baseline: the next save would misread the honest
// on-disk revision as another runtime's write and fork a recovery
// branch. Anchor the baseline on digest+version only until a
// successful save re-learns the revision.
s.markPersistedRevisionUnknown(path, digest, s.version, s.rewriteVersion)
} else {
revision := int64(0)
if ok {
revision = meta.Revision
}
s.markPersisted(path, digest, s.version, revision, s.rewriteVersion)
}
s.markPersisted(path, digest, s.version, revision)
}
return s, nil
}
@@ -1190,7 +1477,7 @@ func reconcileOverlongSessionFilenames(dir string) error {
renamed := map[string]string{} // old branch ID -> new branch ID
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, guardianSidecarSuffix) {
if e.IsDir() || !store.IsSessionTranscriptName(name) {
continue
}
if len(name) <= maxSessionBasenameBytes {
@@ -1314,6 +1601,9 @@ func migrateSessionSidecars(oldPath, newPath, newID string) error {
}
for _, pair := range [][2]string{
{store.SessionGoalState(oldPath), store.SessionGoalState(newPath)},
{store.SessionEventLog(oldPath), store.SessionEventLog(newPath)},
{store.SessionEventIndex(oldPath), store.SessionEventIndex(newPath)},
{store.SessionConflictLog(oldPath), store.SessionConflictLog(newPath)},
{store.SessionCheckpointDir(oldPath), store.SessionCheckpointDir(newPath)},
{store.SessionJobsDir(oldPath), store.SessionJobsDir(newPath)},
} {
@@ -1339,7 +1629,7 @@ func reparentSessionBranches(dir string, renamed map[string]string) error {
var errs []error
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, guardianSidecarSuffix) {
if e.IsDir() || !store.IsSessionTranscriptName(name) {
continue
}
if len(name)+len(".meta") > nameMaxBytes {
+697 -2
View File
@@ -11,6 +11,7 @@ import (
"time"
"reasonix/internal/provider"
"reasonix/internal/store"
)
// touch sets a file's mtime to t. Used by the listing-order test so it
@@ -144,6 +145,337 @@ func TestSaveSnapshotAllowsAppendFromDiskPrefix(t *testing.T) {
}
}
// TestSaveSnapshotAppendsAcrossInterruptedToolCallTail is the mid-turn autosave
// shape from the field: a snapshot lands between an assistant tool call and its
// still-running result, so the transcript on disk ends with a dangling call
// that LoadSession answers with a fabricated placeholder. The live session then
// records the real result and keeps going. The next snapshot is a pure append
// over the bytes on disk and must land as one — not collide with the
// placeholder, misread the turn as divergence, and fork a recovery branch.
func TestSaveSnapshotAppendsAcrossInterruptedToolCallTail(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
s.Add(provider.Message{
Role: provider.RoleAssistant, Content: "Running it.",
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("mid-turn SaveSnapshot: %v", err)
}
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Build passed."})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot after tool result: %v", err)
}
replay, err := replaySessionEventLog(SessionEventLogPath(path))
if err != nil {
t.Fatalf("replay event log: %v", err)
}
if replay.damaged {
t.Fatal("event log damaged after appending over an interrupted tool tail")
}
if replay.records != 2 {
t.Fatalf("event log records = %d, want 2 (bootstrap replace + append)", replay.records)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 5 {
t.Fatalf("message count after append snapshot = %d, want 5", got)
}
if got := loaded.Messages[3].Content; got != "ok" {
t.Fatalf("tool result after round-trip = %q, want %q", got, "ok")
}
if loaded.normalizedDirty {
t.Fatal("transcript still needs repair after appending the real tool result")
}
}
// TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail covers the
// same raw-prefix fallback when a multi-call assistant turn already has some
// tool results on disk. LoadSession fabricates placeholders only for the still
// unanswered calls; the live session later appends the real remaining results.
func TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect and test"})
s.Add(provider.Message{
Role: provider.RoleAssistant, Content: "I will run a few checks.",
ToolCalls: []provider.ToolCall{
{ID: "call_read", Name: "read_file", Arguments: `{"path":"main.go"}`},
{ID: "call_test", Name: "bash", Arguments: `{"cmd":"go test ./..."}`},
{ID: "call_status", Name: "bash", Arguments: `{"cmd":"git status --short"}`},
},
})
s.Add(provider.Message{Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_read", Content: "package main"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("partial multi-tool SaveSnapshot: %v", err)
}
loadedPartial, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession partial: %v", err)
}
if !loadedPartial.normalizedDirty {
t.Fatal("partial multi-tool load should need placeholder repair")
}
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_test", Content: "ok"})
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_status", Content: "clean"})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "All checks passed."})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot after remaining multi-tool results: %v", err)
}
events := readSessionEventsForTest(t, path)
if len(events) != 2 || events[1].Type != sessionEventTypeAppend {
t.Fatalf("events after multi-tool append = %+v, want trailing append", events)
}
if events[1].MessageIndex != 4 || len(events[1].Messages) != 3 {
t.Fatalf("multi-tool append event index=%d len=%d, want index 4 len 3", events[1].MessageIndex, len(events[1].Messages))
}
replay, err := replaySessionEventLog(SessionEventLogPath(path))
if err != nil {
t.Fatalf("replay event log: %v", err)
}
if replay.damaged {
t.Fatal("event log damaged after appending remaining multi-tool results")
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession final: %v", err)
}
if got := len(loaded.Messages); got != 7 {
t.Fatalf("message count after multi-tool append = %d, want 7", got)
}
if got := loaded.Messages[4].Content; got != "ok" {
t.Fatalf("second tool result after round-trip = %q, want ok", got)
}
if got := loaded.Messages[5].Content; got != "clean" {
t.Fatalf("third tool result after round-trip = %q, want clean", got)
}
if loaded.normalizedDirty {
t.Fatal("multi-tool transcript still needs repair after real results landed")
}
}
// TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp covers the turn that
// stays interrupted (cancel, crash recovery with nothing new in memory):
// re-snapshotting the exact bytes on disk must be a no-op, not a stale-prefix
// conflict against the placeholder the load-time repair fabricated.
func TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
s.Add(provider.Message{
Role: provider.RoleAssistant, Content: "Running it.",
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("mid-turn SaveSnapshot: %v", err)
}
logBefore, err := os.ReadFile(SessionEventLogPath(path))
if err != nil {
t.Fatalf("ReadFile event log: %v", err)
}
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot unchanged: %v", err)
}
logAfter, err := os.ReadFile(SessionEventLogPath(path))
if err != nil {
t.Fatalf("ReadFile event log after no-op snapshot: %v", err)
}
if string(logBefore) != string(logAfter) {
t.Fatal("no-op snapshot rewrote the event log")
}
if revision, _, err := sessionContentRevision(path); err != nil || revision != 1 {
t.Fatalf("revision after no-op snapshot = %d (err %v), want 1", revision, err)
}
}
// TestSaveRewriteOwnedAcrossInterruptedToolCallTail: compaction rewrites the
// in-memory history while the transcript on disk still ends with the dangling
// call a mid-turn snapshot left behind. Ownership is anchored on the raw bytes
// this session wrote; the placeholder fabricated on load must not revoke it.
func TestSaveRewriteOwnedAcrossInterruptedToolCallTail(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
s.Add(provider.Message{
Role: provider.RoleAssistant, Content: "Running it.",
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("mid-turn SaveSnapshot: %v", err)
}
s.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "[compacted] run the build"},
})
if err := s.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite after compaction: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 2 {
t.Fatalf("message count after owned rewrite = %d, want 2", got)
}
if got := loaded.Messages[1].Content; got != "[compacted] run the build" {
t.Fatalf("compacted message after round-trip = %q", got)
}
}
// TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable: a session resumed
// from an interrupted tool tail carries the load-time repair in memory, so its
// transcript is one message longer than what the event log replays. The next
// snapshot must not take the append shortcut with that inflated index — the
// chain-broken append event would be discarded on replay, silently dropping
// the whole new turn from disk. It must fall back to a full rewrite that
// persists the repair and the new turn together.
func TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
s.Add(provider.Message{
Role: provider.RoleAssistant, Content: "Running it.",
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("mid-turn SaveSnapshot: %v", err)
}
// Crash + reopen: the resume load answers the dangling call with a
// placeholder, then the user runs another turn.
resumed, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession resume: %v", err)
}
if !resumed.normalizedDirty {
t.Fatal("resume load should carry a pending repair for the dangling call")
}
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "try again"})
resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
if err := resumed.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot after dirty resume: %v", err)
}
replay, err := replaySessionEventLog(SessionEventLogPath(path))
if err != nil {
t.Fatalf("replay event log: %v", err)
}
if replay.damaged {
t.Fatal("event log chain broken by the post-resume snapshot")
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession reload: %v", err)
}
if got := len(loaded.Messages); got != 6 {
t.Fatalf("message count after post-resume snapshot = %d, want 6", got)
}
if got := loaded.Messages[3].ToolCallID; got != "call_1" {
t.Fatalf("placeholder tool result not persisted; message 3 tool_call_id = %q", got)
}
if got := loaded.Messages[5].Content; got != "done" {
t.Fatalf("new turn after round-trip = %q, want %q", got, "done")
}
}
// TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair covers a
// same-length load-time repair: truncated tool-call JSON is fixed in memory, but
// appending against the repaired view would leave the broken arguments on disk.
// The snapshot must rewrite so the repair and new turn persist together.
func TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run tests"})
s.Add(provider.Message{
Role: provider.RoleAssistant,
Content: "Running tests.",
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"go test ./...`}},
})
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot with truncated args: %v", err)
}
resumed, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession resume: %v", err)
}
if !resumed.normalizedDirty {
t.Fatal("resume load should carry a pending repair for truncated tool arguments")
}
const repairedArgs = `{"cmd":"go test ./..."}`
if got := resumed.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
t.Fatalf("repaired args on resume = %q, want %q", got, repairedArgs)
}
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "summarize"})
resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "Tests passed."})
if err := resumed.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot after truncated-args resume: %v", err)
}
events := readSessionEventsForTest(t, path)
if len(events) != 2 || events[1].Type != sessionEventTypeReplace || events[1].Reason != "snapshot" {
t.Fatalf("events after truncated-args repair = %+v, want trailing snapshot replace", events)
}
replay, err := replaySessionEventLog(SessionEventLogPath(path))
if err != nil {
t.Fatalf("replay event log: %v", err)
}
if replay.damaged {
t.Fatal("event log damaged after truncated-args repair rewrite")
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession reload: %v", err)
}
if loaded.normalizedDirty {
t.Fatal("truncated-args repair was not persisted")
}
if got := loaded.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
t.Fatalf("persisted args = %q, want %q", got, repairedArgs)
}
if got := loaded.Messages[5].Content; got != "Tests passed." {
t.Fatalf("new turn after round-trip = %q, want %q", got, "Tests passed.")
}
}
// TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot: the
// recovery-needed check must also judge coverage against the pre-repair bytes.
// Here the stored transcript equals the snapshot exactly, but normalization
// backfills an empty tool-call name on load; that repair must not make the
// disk look like it fails to cover the snapshot and fork a pointless recovery.
func TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
s.Add(provider.Message{
Role: provider.RoleAssistant, Content: "Running it.",
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "", Arguments: `{"cmd":"make"}`}},
})
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
if err := s.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
_, err := s.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
if !errors.Is(err, ErrSessionRecoveryNotNeeded) {
t.Fatalf("SaveRecoveryBranch err = %v, want ErrSessionRecoveryNotNeeded", err)
}
}
func TestSaveSnapshotAppendsWithoutReplacingPrefixFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
base := NewSession("sys")
@@ -444,6 +776,176 @@ func TestSaveSnapshotSameContentByOtherRuntimeKeepsClonedBaselineWritable(t *tes
}
}
func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
staleBaseline := s.persistState(path)
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot prefix append: %v", err)
}
prefixMeta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
}
if prefixMeta.Revision == staleBaseline.revision {
t.Fatalf("prefix revision did not advance: %d", prefixMeta.Revision)
}
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot exact append from stale revision baseline: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession appended: %v", err)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
t.Fatalf("tail after stale-baseline append = %q, want two", got)
}
advancedMeta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
}
if advancedMeta.Revision != prefixMeta.Revision+1 {
t.Fatalf("revision after stale-baseline append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after stale-baseline append = %v err=%v, want none", matches, err)
}
}
func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys v1")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
staleBaseline := s.persistState(path)
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot prefix append: %v", err)
}
prefixMeta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
}
// A resume swapped the system prompt, then the turn appended a message —
// while the persistence baseline still points at the first save.
msgs := s.Snapshot()
msgs[0] = provider.Message{Role: provider.RoleSystem, Content: "sys v2"}
msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "two"})
s.Replace(msgs)
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot compatible-system append from stale baseline: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession appended: %v", err)
}
if got := loaded.Messages[0].Content; got != "sys v2" {
t.Fatalf("system after compatible append = %q, want sys v2", got)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
t.Fatalf("tail after compatible append = %q, want two", got)
}
advancedMeta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
}
if advancedMeta.Revision != prefixMeta.Revision+1 {
t.Fatalf("revision after compatible append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after compatible append = %v err=%v, want none", matches, err)
}
}
func TestSaveSnapshotRefusesStaleBaselineAppendOverRewoundTranscript(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot extend: %v", err)
}
// Another runtime rewinds the transcript below this session's baseline
// (e.g. a cancelled turn truncated the partial assistant reply).
other, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession other: %v", err)
}
other.Replace(other.Snapshot()[:2])
if err := other.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite rewind: %v", err)
}
// Appending from the stale baseline would resurrect the rewound suffix.
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
t.Fatalf("SaveSnapshot over rewound transcript err = %v, want ErrSessionSnapshotConflict", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession after refused append: %v", err)
}
if got := len(loaded.Messages); got != 2 {
t.Fatalf("messages after refused append = %d, want rewound 2", got)
}
}
func TestSaveRewriteAllowsOwnedRewriteAfterLedgerReset(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial turn"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
// The meta sidecar (revision ledger) is lost — e.g. swept by a cleanup
// that deleted session-adjacent files. The transcript itself is intact.
if err := os.Remove(BranchMetaPath(path)); err != nil {
t.Fatalf("remove sidecar: %v", err)
}
// A cancelled turn strips the partial reply and flushes via SaveRewrite.
msgs := s.Snapshot()
s.Replace(msgs[:len(msgs)-1])
if err := s.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite after ledger reset: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession truncated: %v", err)
}
if got := len(loaded.Messages); got != 2 {
t.Fatalf("messages after owned rewrite = %d, want 2", got)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "first" {
t.Fatalf("tail after owned rewrite = %q, want first", got)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
}
}
func TestSaveSnapshotStillPersistsNormalizedRepair(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
mal := NewSession("sys")
@@ -527,7 +1029,7 @@ func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
}
}
func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
base := NewSession("sys")
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
@@ -540,6 +1042,9 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
if err != nil {
t.Fatalf("LoadSession stale: %v", err)
}
// Another runtime healed the ledger over identical content: the revision
// advanced under a foreign writer id, but the recorded digest still
// describes the exact bytes this session loaded and owns.
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
@@ -550,13 +1055,67 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
t.Fatalf("bump revision: %v", err)
}
stale.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summarized first"},
})
if err := stale.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite over same-content stamp: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
}
advanced, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
}
if advanced.Revision != meta.Revision+1 {
t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
}
}
func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
base := NewSession("sys")
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := base.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
stale, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession stale: %v", err)
}
// A foreign stamp whose digest disagrees with the on-disk transcript is
// the aftermath of a save whose bytes and record split — the transcript
// cannot be attributed, so the rewrite must fall to the conflict path.
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
meta.Revision++
meta.WriterID = "other-writer"
meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatalf("stamp foreign digest: %v", err)
}
stale.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summarized first"},
})
err = stale.SaveRewrite(path)
if !errors.Is(err, ErrSessionSnapshotConflict) {
t.Fatalf("SaveRewrite revision conflict err = %v, want ErrSessionSnapshotConflict", err)
t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
}
var conflict *SessionSnapshotConflictError
if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {
@@ -798,6 +1357,86 @@ func TestSaveRecoveryBranchSkipsPureStalePrefix(t *testing.T) {
}
}
func divergedSessionPair(t *testing.T, dir, name string) (string, *Session) {
t.Helper()
path := filepath.Join(dir, name)
current := NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(path); err != nil {
t.Fatalf("Save current: %v", err)
}
stale := NewSession("sys")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
return path, stale
}
func stampRecoveryMeta(t *testing.T, path string, depth int) {
t.Helper()
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
meta.Recovered = true
meta.RecoveryDepth = depth
if err := SaveBranchMeta(path, meta); err != nil {
t.Fatalf("SaveBranchMeta: %v", err)
}
}
func TestSaveRecoveryBranchStampsAndCapsChainDepth(t *testing.T) {
dir := t.TempDir()
// Forking from a normal session stamps depth 1.
path, stale := divergedSessionPair(t, dir, "session.jsonl")
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
if info.Meta.RecoveryDepth != 1 {
t.Fatalf("first fork depth = %d, want 1", info.Meta.RecoveryDepth)
}
// Forking from a recovery branch increments the chain depth.
deeper, staleDeeper := divergedSessionPair(t, dir, "deeper.jsonl")
stampRecoveryMeta(t, deeper, 1)
info, err = staleDeeper.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: deeper})
if err != nil {
t.Fatalf("SaveRecoveryBranch from depth 1: %v", err)
}
if info.Meta.RecoveryDepth != 2 {
t.Fatalf("nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
}
// A legacy recovery meta without the depth field counts as depth 1.
legacy, staleLegacy := divergedSessionPair(t, dir, "legacy.jsonl")
stampRecoveryMeta(t, legacy, 0)
info, err = staleLegacy.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: legacy})
if err != nil {
t.Fatalf("SaveRecoveryBranch from legacy recovery: %v", err)
}
if info.Meta.RecoveryDepth != 2 {
t.Fatalf("legacy nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
}
// A parent at the cap refuses to fork deeper.
capped, staleCapped := divergedSessionPair(t, dir, "capped.jsonl")
stampRecoveryMeta(t, capped, SessionRecoveryMaxDepth)
if _, err := staleCapped.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: capped}); !errors.Is(err, ErrSessionRecoveryDepthExceeded) {
t.Fatalf("SaveRecoveryBranch at cap err = %v, want ErrSessionRecoveryDepthExceeded", err)
}
forks, err := filepath.Glob(filepath.Join(dir, "capped-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob: %v", err)
}
if len(forks) != 0 {
t.Fatalf("capped parent still forked: %v", forks)
}
}
func TestSaveRecoveryBranchDedupesByDigest(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
current := NewSession("sys")
@@ -1372,3 +2011,59 @@ func readSessionEventsForTest(t *testing.T, path string) []sessionEventRecord {
}
return out
}
// TestReconcileOverlongMigratesDiagnosticSidecars pins two halves of one fix:
// overlong-name reconciliation must skip .events.jsonl / .conflicts.jsonl
// sidecars instead of renaming each into a fake session with fabricated meta,
// and the owning transcript's rename must carry those sidecars along instead
// of orphaning them under the retired stem.
func TestReconcileOverlongMigratesDiagnosticSidecars(t *testing.T) {
dir := t.TempDir()
// 236-byte transcript name: past the 224 reconcile bound, while its
// .events.jsonl (243) and .conflicts.jsonl (246) still fit under 255 —
// exactly the window where the old suffix filter mistook them for
// overlong sessions.
id := strings.Repeat("s", 230)
oldPath := filepath.Join(dir, id+".jsonl")
content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(store.SessionEventLog(oldPath),
[]byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"hello"}]}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(store.SessionConflictLog(oldPath),
[]byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := ReconcileSessionSidecars(dir); err != nil {
t.Fatalf("ReconcileSessionSidecars: %v", err)
}
newPath := filepath.Join(dir, recoveryParentStem(id)+".jsonl")
if _, err := os.Stat(newPath); err != nil {
t.Fatalf("renamed transcript missing: %v", err)
}
if _, err := os.Stat(store.SessionEventLog(newPath)); err != nil {
t.Fatalf("event log not migrated with the rename: %v", err)
}
if _, err := os.Stat(store.SessionConflictLog(newPath)); err != nil {
t.Fatalf("conflict log not migrated with the rename: %v", err)
}
for _, gone := range []string{oldPath, store.SessionEventLog(oldPath), store.SessionConflictLog(oldPath)} {
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Fatalf("%s still present under the retired stem (err=%v)", filepath.Base(gone), err)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if store.IsSessionTranscriptName(e.Name()) && e.Name() != filepath.Base(newPath) {
t.Fatalf("reconcile fabricated an extra session from a sidecar: %s", e.Name())
}
}
}
+46 -16
View File
@@ -18,7 +18,15 @@ type Session struct {
Messages []provider.Message
version uint64
rewriteVersion int // bumped each time the log is rewritten (compact/fold)
persisted sessionPersistState
// persistedRewriteVersion is the highest rewriteVersion whose transcript
// has fully reached disk. It lives on the Session — not on the controller
// — so swapping session objects can never orphan or misattribute the
// baseline: NeedsRewriteSave always compares a session against its own
// save history. Save paths advance it under s.mu with the rewriteVersion
// captured alongside the message snapshot, never a re-read one, so a
// compaction landing mid-save stays unpersisted.
persistedRewriteVersion int
persisted sessionPersistState
// normalizedDirty is set when LoadSession repaired the history on the way in
// (empty tool-call names, dangling calls, truncated args, …). The repair
// already lives in Messages, so the next Save persists it automatically as
@@ -29,6 +37,12 @@ type Session struct {
// or corrupt and returned the replayable prefix (or the .jsonl checkpoint).
// The next save heals the log with a rewrite-and-compact.
eventLogDamaged bool
// rawMessages preserves the pre-normalization transcript when the load-time
// repairs changed it (normalizedDirty). It is only meaningful on a freshly
// loaded Session: checkSnapshotWrite compares a pending snapshot against
// what is actually on disk, and the repaired view no longer represents
// those bytes — a session that kept running extends the raw transcript.
rawMessages []provider.Message
}
// NewSession initializes a session with an optional system prompt.
@@ -61,7 +75,7 @@ func (s *Session) Replace(msgs []provider.Message) {
// while a turn appends. Frontends (History, Save) use it instead of touching the
// live slice.
func (s *Session) Snapshot() []provider.Message {
msgs, _ := s.snapshotWithVersion()
msgs, _, _ := s.snapshotWithVersion()
return msgs
}
@@ -91,12 +105,13 @@ func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
version++
}
return &Session{
Messages: append([]provider.Message(nil), msgs...),
version: version,
rewriteVersion: s.rewriteVersion,
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
Messages: append([]provider.Message(nil), msgs...),
version: version,
rewriteVersion: s.rewriteVersion,
persistedRewriteVersion: s.persistedRewriteVersion,
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
}
}
@@ -118,19 +133,24 @@ func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Sessi
version++
}
return &Session{
Messages: append([]provider.Message(nil), msgs...),
version: version,
rewriteVersion: s.rewriteVersion,
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
Messages: append([]provider.Message(nil), msgs...),
version: version,
rewriteVersion: s.rewriteVersion,
persistedRewriteVersion: s.persistedRewriteVersion,
persisted: s.persisted,
normalizedDirty: s.normalizedDirty,
eventLogDamaged: s.eventLogDamaged,
}, true
}
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64) {
// snapshotWithVersion returns the messages together with the version and
// rewriteVersion they were captured under, in one lock window: save paths
// persist exactly this rewriteVersion as the new baseline, so a rewrite that
// lands after the capture cannot be misrecorded as saved.
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]provider.Message(nil), s.Messages...), s.version
return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
}
// RewriteVersion returns the current rewrite version.
@@ -140,6 +160,16 @@ func (s *Session) RewriteVersion() int {
return s.rewriteVersion
}
// NeedsRewriteSave reports whether the history has been rewritten in memory
// (compaction, prune) since the last successful full save of this session.
// Snapshot paths use it to decide that the next write must be an owned
// rewrite instead of an append.
func (s *Session) NeedsRewriteSave() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.rewriteVersion > s.persistedRewriteVersion
}
// IncrementRewrite bumps the rewrite version by 1.
func (s *Session) IncrementRewrite() {
s.mu.Lock()
+35 -16
View File
@@ -93,26 +93,35 @@ func TryAcquireSessionLease(path string) (*SessionLease, error) {
}
// TryReclaimCurrentProcessSessionLease re-acquires a lease whose in-process
// owner entry was orphaned (a lease dropped without Release). It only succeeds
// when the on-disk lease info identifies the current process AND the OS lease
// lock is free: an active holder keeps its lock file locked, so reclaiming
// from it fails with ErrSessionLeaseHeld without touching the holder's entry.
// owner entry was orphaned (a lease dropped without Release). The OS lease
// lock is the arbiter: an active holder keeps its lock file locked for the
// whole hold, so reclaiming from one fails with ErrSessionLeaseHeld without
// touching the holder's entry. Holding the lock proves nobody does, which
// also covers metadata-damage states — a missing or unreadable lease info
// (deleted by the user, quarantined by AV, torn by a crash) with a free lock
// is a leftover, not a holder, and must not wedge the session as busy.
func TryReclaimCurrentProcessSessionLease(path string) (*SessionLease, error) {
path = canonicalSessionSavePath(path)
info, err := LoadSessionLeaseInfo(path)
if err != nil {
if os.IsNotExist(err) {
// The holder finished releasing between the caller's failed
// acquire and now; a regular acquire can win the lease cleanly.
return TryAcquireSessionLease(path)
switch {
case err == nil:
if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() {
// A readable info naming another live runtime: never steal it.
// (A crashed foreign leftover is separated from a live holder by
// the lock probe in SessionLeaseHeldByOtherRuntime; reclaim is
// only for leases this process lost track of.)
return nil, &SessionLeaseError{Path: path, Info: info}
}
// Unreadable lease info means the holder cannot be identified, so
// reclaiming is unsafe. Report the lease as held rather than leaking
// a raw filesystem error to callers that surface it to users.
return nil, &SessionLeaseError{Path: path}
}
if info == nil || info.PID != os.Getpid() || info.WriterID != SessionWriterID() {
return nil, &SessionLeaseError{Path: path, Info: info}
case os.IsNotExist(err):
// The holder finished releasing (info removed first) or the sidecar
// was deleted out from under an orphaned entry. Either way the lock
// probe below decides; info identity has nothing left to say.
info = nil
default:
// Unreadable info hides the holder's identity, but the lock still
// tells the truth: a live holder keeps it locked. Fall through to the
// probe instead of wedging on metadata damage.
info = nil
}
unlock, err := tryLockSessionLeaseFile(path)
if err != nil {
@@ -203,6 +212,16 @@ func (l *SessionLease) Release() {
// Only remove the entry this lease owns: after a reclaim the map may
// already point at a newer lease for the same path.
sessionLeaseOwners.CompareAndDelete(l.path, l.ownerID)
// Best-effort retirement of the lock sidecars this session no longer
// needs. Historically they were left behind on every release and only
// swept on the next boot reconcile, so ordinary use accumulated
// .lock/.lease.lock files (#6014). The helpers re-take each lock
// non-blocking and delete it atomically with the release, so a new
// holder or an in-flight save simply turns this into a no-op. This
// must run after CompareAndDelete: the lease-lock helper skips paths
// the owner registry still reports as held by this process.
_ = removeStaleSessionLeaseLockSidecar(l.path, store.SessionLeaseLock(l.path))
_ = removeStaleSessionLockSidecar(l.path, store.SessionLockFile(l.path))
})
}
+183 -66
View File
@@ -7,18 +7,35 @@ import (
"sync"
"testing"
"time"
"reasonix/internal/store"
)
// leaseTestPath returns a session path in "user shape" — mixed case, exactly
// as desktop/CLI callers pass it — plus its canonical registry key for
// internal-state setup and assertions. Tests must feed the user shape to the
// API under test: feeding pre-canonicalized paths is how the Windows
// case-fold mismatch (#5999) escaped this suite. On non-Windows hosts the two
// forms are identical; on Windows they differ and exercise the fold.
func leaseTestPath(t *testing.T) (userPath, key string) {
t.Helper()
userPath = filepath.Join(t.TempDir(), "Sessions-Dir", "Session-Test.jsonl")
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
return userPath, canonicalSessionSavePath(userPath)
}
func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
first, err := TryAcquireSessionLease(path)
userPath, _ := leaseTestPath(t)
first, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("first TryAcquireSessionLease: %v", err)
}
if first.Path() == "" {
t.Fatal("first lease path is empty")
}
info, err := LoadSessionLeaseInfo(path)
info, err := LoadSessionLeaseInfo(userPath)
if err != nil {
t.Fatalf("LoadSessionLeaseInfo: %v", err)
}
@@ -26,7 +43,7 @@ func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
t.Fatalf("lease info = %+v, want writer metadata", info)
}
second, err := TryAcquireSessionLease(path)
second, err := TryAcquireSessionLease(userPath)
if !errors.Is(err, ErrSessionLeaseHeld) {
t.Fatalf("second TryAcquireSessionLease err = %v, want ErrSessionLeaseHeld", err)
}
@@ -36,7 +53,7 @@ func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
}
first.Release()
third, err := TryAcquireSessionLease(path)
third, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("third TryAcquireSessionLease after release: %v", err)
}
@@ -44,43 +61,112 @@ func TestSessionLeaseRejectsConcurrentWriterAndReleases(t *testing.T) {
}
func TestSessionLeaseReclaimsCurrentProcessStaleOwner(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
path = canonicalSessionSavePath(path)
sessionLeaseOwners.Store(path, struct{}{})
userPath, key := leaseTestPath(t)
sessionLeaseOwners.Store(key, struct{}{})
t.Cleanup(func() {
sessionLeaseOwners.Delete(path)
_ = os.Remove(sessionLeaseInfoPath(path))
sessionLeaseOwners.Delete(key)
_ = os.Remove(sessionLeaseInfoPath(key))
})
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
SessionPath: path,
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
SessionPath: key,
WriterID: SessionWriterID(),
PID: os.Getpid(),
AcquiredAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("SaveSessionLeaseInfo: %v", err)
}
if lease, err := TryAcquireSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
if lease != nil {
lease.Release()
}
t.Fatalf("TryAcquireSessionLease err = %v, want ErrSessionLeaseHeld", err)
}
lease, err := TryReclaimCurrentProcessSessionLease(path)
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
if err != nil {
t.Fatalf("TryReclaimCurrentProcessSessionLease: %v", err)
}
lease.Release()
}
func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
sessionLeaseOwners.Store(path, struct{}{})
func TestSessionLeaseReclaimsOrphanedEntryWithoutInfo(t *testing.T) {
// An orphaned in-process entry whose lease.json was deleted out from
// under it (manual cleanup, AV quarantine). Nothing actually holds the
// session — the OS lock is free — so reclaim must recover instead of
// wedging every rebuild as busy. Before the lock-arbiter rework this
// deadlocked: reclaim fell back to a plain acquire, which re-hit the
// orphaned map entry forever.
userPath, key := leaseTestPath(t)
sessionLeaseOwners.Store(key, uint64(1<<61))
t.Cleanup(func() { sessionLeaseOwners.Delete(key) })
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
if lease != nil {
lease.Release()
}
t.Fatalf("TryAcquireSessionLease err = %v, want ErrSessionLeaseHeld", err)
}
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
if err != nil {
t.Fatalf("TryReclaimCurrentProcessSessionLease without info: %v", err)
}
if _, err := LoadSessionLeaseInfo(userPath); err != nil {
t.Fatalf("reclaim should have rewritten lease info, load err = %v", err)
}
lease.Release()
}
func TestSessionLeaseReclaimsOrphanedEntryWithCorruptInfo(t *testing.T) {
// Same as above but the sidecar is torn (empty/undecodable) rather than
// missing: identity is unreadable, the lock is free, reclaim must win.
userPath, key := leaseTestPath(t)
sessionLeaseOwners.Store(key, uint64(1<<61))
t.Cleanup(func() {
sessionLeaseOwners.Delete(path)
_ = os.Remove(sessionLeaseInfoPath(path))
sessionLeaseOwners.Delete(key)
_ = os.Remove(sessionLeaseInfoPath(key))
})
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
SessionPath: path,
if err := os.WriteFile(sessionLeaseInfoPath(key), []byte("{torn"), 0o644); err != nil {
t.Fatalf("write corrupt lease info: %v", err)
}
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
if err != nil {
t.Fatalf("TryReclaimCurrentProcessSessionLease with corrupt info: %v", err)
}
lease.Release()
}
func TestSessionLeaseReclaimRefusesForeignInfo(t *testing.T) {
// A readable info naming another runtime is never stolen by reclaim,
// even with the lock free — that separation belongs to
// SessionLeaseHeldByOtherRuntime's cleanup, not to reclaim.
userPath, key := leaseTestPath(t)
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
SessionPath: key,
WriterID: "other-host-1234-deadbeef",
PID: os.Getpid() + 1,
AcquiredAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("SaveSessionLeaseInfo: %v", err)
}
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(key)) })
if lease, err := TryReclaimCurrentProcessSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
if lease != nil {
lease.Release()
}
t.Fatalf("TryReclaimCurrentProcessSessionLease err = %v, want ErrSessionLeaseHeld", err)
}
}
func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
userPath, key := leaseTestPath(t)
sessionLeaseOwners.Store(key, struct{}{})
t.Cleanup(func() {
sessionLeaseOwners.Delete(key)
_ = os.Remove(sessionLeaseInfoPath(key))
})
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
SessionPath: key,
WriterID: SessionWriterID(),
PID: os.Getpid(),
AcquiredAt: time.Now().UTC(),
@@ -97,7 +183,7 @@ func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
go func() {
defer wg.Done()
<-start
if lease, err := TryReclaimCurrentProcessSessionLease(path); err == nil && lease != nil {
if lease, err := TryReclaimCurrentProcessSessionLease(userPath); err == nil && lease != nil {
leases <- lease
}
}()
@@ -114,17 +200,17 @@ func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
t.Fatalf("concurrent reclaim produced %d leases, want exactly 1", len(won))
}
// The losers must not have evicted the winner's owner entry.
if _, ok := sessionLeaseOwners.Load(path); !ok {
if _, ok := sessionLeaseOwners.Load(key); !ok {
t.Fatal("winner's owner entry was evicted by a failed concurrent reclaim")
}
if lease, err := TryAcquireSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
if lease != nil {
lease.Release()
}
t.Fatalf("TryAcquireSessionLease while reclaimed lease is held err = %v, want ErrSessionLeaseHeld", err)
}
won[0].Release()
lease, err := TryAcquireSessionLease(path)
lease, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease after release: %v", err)
}
@@ -132,24 +218,24 @@ func TestSessionLeaseConcurrentReclaimSingleWinner(t *testing.T) {
}
func TestSessionLeaseReclaimRefusesActiveHolder(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
holder, err := TryAcquireSessionLease(path)
userPath, key := leaseTestPath(t)
holder, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer holder.Release()
if lease, err := TryReclaimCurrentProcessSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
if lease, err := TryReclaimCurrentProcessSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
if lease != nil {
lease.Release()
}
t.Fatalf("TryReclaimCurrentProcessSessionLease err = %v, want ErrSessionLeaseHeld", err)
}
// The failed reclaim must leave the holder's owner entry intact.
if _, ok := sessionLeaseOwners.Load(path); !ok {
if _, ok := sessionLeaseOwners.Load(key); !ok {
t.Fatal("active holder's owner entry was evicted by a failed reclaim")
}
if lease, err := TryAcquireSessionLease(path); !errors.Is(err, ErrSessionLeaseHeld) {
if lease, err := TryAcquireSessionLease(userPath); !errors.Is(err, ErrSessionLeaseHeld) {
if lease != nil {
lease.Release()
}
@@ -158,16 +244,17 @@ func TestSessionLeaseReclaimRefusesActiveHolder(t *testing.T) {
}
func TestSessionLeaseReclaimAfterHolderReleased(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
holder, err := TryAcquireSessionLease(path)
userPath, _ := leaseTestPath(t)
holder, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
holder.Release()
// The holder released between the caller's failed acquire and the
// reclaim: the lease info file is gone and a plain acquire must win.
lease, err := TryReclaimCurrentProcessSessionLease(path)
// reclaim: the lease info file is gone and the lock is free, so the
// reclaim must win the lease cleanly.
lease, err := TryReclaimCurrentProcessSessionLease(userPath)
if err != nil {
t.Fatalf("TryReclaimCurrentProcessSessionLease after release: %v", err)
}
@@ -175,96 +262,126 @@ func TestSessionLeaseReclaimAfterHolderReleased(t *testing.T) {
}
func TestSessionLeaseStaleReleaseKeepsNewOwnerEntry(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
stale, err := TryAcquireSessionLease(path)
userPath, key := leaseTestPath(t)
stale, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
// Simulate a reclaim that took over the entry while the stale lease was
// still alive: the map now names a different owner.
sessionLeaseOwners.Store(path, uint64(1<<62))
t.Cleanup(func() { sessionLeaseOwners.Delete(path) })
sessionLeaseOwners.Store(key, uint64(1<<62))
t.Cleanup(func() { sessionLeaseOwners.Delete(key) })
stale.Release()
if _, ok := sessionLeaseOwners.Load(path); !ok {
if _, ok := sessionLeaseOwners.Load(key); !ok {
t.Fatal("stale Release evicted the new owner's entry")
}
}
func TestSessionLeaseHeldByOtherRuntime(t *testing.T) {
t.Run("no lease", func(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
if SessionLeaseHeldByOtherRuntime(path) {
userPath, _ := leaseTestPath(t)
if SessionLeaseHeldByOtherRuntime(userPath) {
t.Fatal("unheld session reported as held by another runtime")
}
})
t.Run("held by this process", func(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
lease, err := TryAcquireSessionLease(path)
userPath, _ := leaseTestPath(t)
lease, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer lease.Release()
if SessionLeaseHeldByOtherRuntime(path) {
if SessionLeaseHeldByOtherRuntime(userPath) {
t.Fatal("own lease reported as held by another runtime")
}
})
t.Run("foreign info with live lock", func(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
unlock, err := tryLockSessionLeaseFile(path)
userPath, key := leaseTestPath(t)
unlock, err := tryLockSessionLeaseFile(key)
if err != nil {
t.Fatalf("tryLockSessionLeaseFile: %v", err)
}
defer unlock()
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
SessionPath: path,
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
SessionPath: key,
WriterID: "other-host-1234-deadbeef",
PID: os.Getpid() + 1,
AcquiredAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("SaveSessionLeaseInfo: %v", err)
}
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(path)) })
if !SessionLeaseHeldByOtherRuntime(path) {
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(key)) })
if !SessionLeaseHeldByOtherRuntime(userPath) {
t.Fatal("foreign-held session not reported as held by another runtime")
}
})
t.Run("foreign info from crashed process", func(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
if err := SaveSessionLeaseInfo(path, SessionLeaseInfo{
SessionPath: path,
userPath, key := leaseTestPath(t)
if err := SaveSessionLeaseInfo(key, SessionLeaseInfo{
SessionPath: key,
WriterID: "other-host-1234-deadbeef",
PID: os.Getpid() + 1,
AcquiredAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("SaveSessionLeaseInfo: %v", err)
}
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(path)) })
t.Cleanup(func() { _ = os.Remove(sessionLeaseInfoPath(key)) })
// Info file left behind but the lock is free: the holder crashed, so
// the session is not considered held.
if SessionLeaseHeldByOtherRuntime(path) {
if SessionLeaseHeldByOtherRuntime(userPath) {
t.Fatal("crashed holder's leftover info reported as held")
}
if _, err := os.Stat(sessionLeaseInfoPath(path)); !os.IsNotExist(err) {
if _, err := os.Stat(sessionLeaseInfoPath(key)); !os.IsNotExist(err) {
t.Fatalf("crashed holder's leftover info should be removed, stat err = %v", err)
}
})
t.Run("corrupt info from crashed process", func(t *testing.T) {
path := canonicalSessionSavePath(filepath.Join(t.TempDir(), "session.jsonl"))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(sessionLeaseInfoPath(path), nil, 0o644); err != nil {
userPath, key := leaseTestPath(t)
if err := os.WriteFile(sessionLeaseInfoPath(key), nil, 0o644); err != nil {
t.Fatalf("write corrupt lease info: %v", err)
}
if SessionLeaseHeldByOtherRuntime(path) {
if SessionLeaseHeldByOtherRuntime(userPath) {
t.Fatal("corrupt crashed holder info reported as held")
}
if _, err := os.Stat(sessionLeaseInfoPath(path)); !os.IsNotExist(err) {
if _, err := os.Stat(sessionLeaseInfoPath(key)); !os.IsNotExist(err) {
t.Fatalf("corrupt lease info should be removed, stat err = %v", err)
}
})
}
func TestSessionLeaseReleaseRetiresLockSidecars(t *testing.T) {
userPath, key := leaseTestPath(t)
lease, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
leaseLock := store.SessionLeaseLock(key)
if _, err := os.Stat(leaseLock); err != nil {
t.Fatalf("lease lock should exist while held: %v", err)
}
lease.Release()
if _, err := os.Stat(leaseLock); !os.IsNotExist(err) {
t.Fatalf("lease lock should be retired on release, stat err = %v", err)
}
if _, err := os.Stat(store.SessionLockFile(key)); !os.IsNotExist(err) {
t.Fatalf("save lock should be retired on release, stat err = %v", err)
}
// A release racing a live successor must not strip the successor's lock.
first, err := TryAcquireSessionLease(userPath)
if err != nil {
t.Fatalf("reacquire: %v", err)
}
second, err := TryAcquireSessionLease(userPath)
if !errors.Is(err, ErrSessionLeaseHeld) {
if second != nil {
second.Release()
}
t.Fatalf("second acquire err = %v, want ErrSessionLeaseHeld", err)
}
if _, err := os.Stat(leaseLock); err != nil {
t.Fatalf("holder's lease lock must survive a failed acquire: %v", err)
}
first.Release()
}
+652
View File
@@ -0,0 +1,652 @@
package agent
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/provider"
)
// shrinkMetaReadBackoffs keeps corrupt-sidecar tests fast. Only the pacing of
// the torn-read retries changes; the retry-then-fail semantics stay intact.
func shrinkMetaReadBackoffs(t *testing.T) {
t.Helper()
old := branchMetaReadBackoffs
branchMetaReadBackoffs = []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond}
t.Cleanup(func() { branchMetaReadBackoffs = old })
}
func assertNoRecoveryBranches(t *testing.T, sessionPath string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(filepath.Dir(sessionPath), "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
if len(matches) != 0 {
t.Fatalf("unexpected recovery branches: %v", matches)
}
}
// A meta sidecar that exists but cannot be parsed must fail the save instead
// of being silently treated as revision 0 — the desync that used to fork
// bogus recovery branches. The unreadable ledger must also survive untouched
// so a healed read can pick up the real revision.
func TestSaveFailsClosedOnCorruptMetaLedger(t *testing.T) {
shrinkMetaReadBackoffs(t)
path := filepath.Join(t.TempDir(), "session.jsonl")
metaPath := BranchMetaPath(path)
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
good, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read good meta: %v", err)
}
corrupt := []byte("{ torn json")
if err := os.WriteFile(metaPath, corrupt, 0o644); err != nil {
t.Fatalf("corrupt meta: %v", err)
}
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
saveErr := s.SaveSnapshot(path)
if saveErr == nil {
t.Fatal("SaveSnapshot with corrupt meta ledger succeeded, want error")
}
if errors.Is(saveErr, ErrSessionSnapshotConflict) {
t.Fatalf("SaveSnapshot with corrupt meta misread as conflict: %v", saveErr)
}
assertNoRecoveryBranches(t, path)
if b, err := os.ReadFile(metaPath); err != nil || string(b) != string(corrupt) {
t.Fatalf("failed save rewrote the unreadable ledger: err=%v content=%q", err, b)
}
onDisk, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession after failed save: %v", err)
}
if got := len(onDisk.Messages); got != 2 {
t.Fatalf("failed save changed transcript: %d messages, want 2", got)
}
// Once the sidecar reads cleanly again the same session saves normally.
if err := os.WriteFile(metaPath, good, 0o644); err != nil {
t.Fatalf("restore meta: %v", err)
}
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot after meta repair: %v", err)
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after repair ok=%v err=%v", ok, err)
}
if meta.Revision != 2 {
t.Fatalf("revision after repaired save = %d, want 2", meta.Revision)
}
}
// An unreadable meta sidecar at load time must not stop the session from
// opening, and the revision-0 placeholder must not arm the CAS check: once
// the sidecar reads cleanly again, appends save without a bogus conflict.
func TestLoadSessionWithUnreadableMetaStillOpensAndAppends(t *testing.T) {
shrinkMetaReadBackoffs(t)
path := filepath.Join(t.TempDir(), "session.jsonl")
metaPath := BranchMetaPath(path)
seed := NewSession("sys")
seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := seed.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot seed: %v", err)
}
good, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read good meta: %v", err)
}
if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
t.Fatalf("corrupt meta: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession with unreadable meta: %v", err)
}
if got := len(loaded.Messages); got != 3 {
t.Fatalf("loaded %d messages, want 3", got)
}
if !loaded.persisted.ok {
t.Fatal("load must still establish a persistence baseline")
}
if loaded.persisted.revisionKnown {
t.Fatal("baseline built without a readable ledger must be revision-unknown")
}
// The tear heals (another runtime's write completes / the corruption was
// transient): the on-disk revision is an honest 1 against this runtime's
// unknown baseline. The append must not be misread as a stale runtime.
if err := os.WriteFile(metaPath, good, 0o644); err != nil {
t.Fatalf("restore meta: %v", err)
}
loaded.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot append after unreadable-meta load: %v", err)
}
assertNoRecoveryBranches(t, path)
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after append ok=%v err=%v", ok, err)
}
if meta.Revision != 2 {
t.Fatalf("revision after append = %d, want 2", meta.Revision)
}
if !loaded.persisted.revisionKnown {
t.Fatal("successful save must re-learn the revision baseline")
}
// With the baseline re-learned, revision CAS is armed again.
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot second append: %v", err)
}
}
// Compaction-style rewrites from a revision-unknown baseline must fall back to
// digest+version ownership instead of failing the revision equality check.
func TestSaveRewriteWithUnreadableMetaBaselineOwnsByDigest(t *testing.T) {
shrinkMetaReadBackoffs(t)
path := filepath.Join(t.TempDir(), "session.jsonl")
metaPath := BranchMetaPath(path)
seed := NewSession("sys")
seed.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
seed.Add(provider.Message{Role: provider.RoleAssistant, Content: "a long detailed answer"})
if err := seed.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot seed: %v", err)
}
good, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read good meta: %v", err)
}
if err := os.WriteFile(metaPath, []byte("{ torn json"), 0o644); err != nil {
t.Fatalf("corrupt meta: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession with unreadable meta: %v", err)
}
if err := os.WriteFile(metaPath, good, 0o644); err != nil {
t.Fatalf("restore meta: %v", err)
}
rewritten := loaded.Snapshot()
rewritten[len(rewritten)-1].Content = "compacted"
loaded.Replace(rewritten)
if err := loaded.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite from unknown-revision baseline: %v", err)
}
assertNoRecoveryBranches(t, path)
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after rewrite ok=%v err=%v", ok, err)
}
if meta.Revision != 2 {
t.Fatalf("revision after rewrite = %d, want 2", meta.Revision)
}
reloaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession after rewrite: %v", err)
}
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "compacted" {
t.Fatalf("rewrite tail = %q, want compacted", got)
}
}
// A missing sidecar is not damage: it is the legitimate revision-0 state of a
// session that never recorded one, and must keep arming the CAS baseline.
func TestMissingMetaRemainsKnownZeroRevisionBaseline(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
if err := os.Remove(BranchMetaPath(path)); err != nil {
t.Fatalf("remove meta: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession without meta: %v", err)
}
if !loaded.persisted.ok || !loaded.persisted.revisionKnown || loaded.persisted.revision != 0 {
t.Fatalf("missing meta baseline = %+v, want known revision 0", loaded.persisted)
}
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot append without meta: %v", err)
}
assertNoRecoveryBranches(t, path)
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recreated ok=%v err=%v", ok, err)
}
if meta.Revision != 1 {
t.Fatalf("recreated revision = %d, want 1", meta.Revision)
}
}
// Losing the event index (a listing accelerator, never read by LoadSession)
// must not fail a save whose transcript and revision already landed, and must
// not leave the baseline behind disk where the next save reads as a conflict.
func TestSaveSnapshotToleratesEventIndexWriteFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
// Squat a directory on the index path so every index write must fail.
if err := os.MkdirAll(SessionEventIndexPath(path), 0o755); err != nil {
t.Fatalf("pre-create index dir: %v", err)
}
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot with failing event index = %v, want nil", err)
}
first, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta first ok=%v err=%v", ok, err)
}
if first.Revision != 1 {
t.Fatalf("first revision = %d, want 1", first.Revision)
}
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("second SaveSnapshot = %v, want nil (baseline must advance despite index failure)", err)
}
second, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta second ok=%v err=%v", ok, err)
}
if second.Revision != 2 {
t.Fatalf("second revision = %d, want 2", second.Revision)
}
assertNoRecoveryBranches(t, path)
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 3 {
t.Fatalf("loaded %d messages, want 3", got)
}
if info, err := os.Stat(SessionEventIndexPath(path)); err != nil || !info.IsDir() {
t.Fatalf("fixture broke: index path no longer a directory (err=%v)", err)
}
}
// Meta-only writers (rename, model stamp) racing content saves must never
// roll the revision ledger backwards or manufacture conflicts.
func TestSessionMetaConcurrentWritersKeepRevisionMonotonic(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot seed: %v", err)
}
if err := RenameSession(path, "seed-title"); err != nil {
t.Fatalf("RenameSession seed: %v", err)
}
if err := SetBranchModelPreserveUpdated(path, "prov/model-seed"); err != nil {
t.Fatalf("SetBranchModelPreserveUpdated seed: %v", err)
}
const saves = 25
stop := make(chan struct{})
var stopOnce sync.Once
stopAll := func() { stopOnce.Do(func() { close(stop) }) }
var wg sync.WaitGroup
defer func() {
stopAll()
wg.Wait()
}()
metaErrCh := make(chan error, 2)
var regressed atomic.Bool
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
if err := RenameSession(path, fmt.Sprintf("title-%d", i)); err != nil {
select {
case metaErrCh <- err:
default:
}
return
}
if err := SetBranchModelPreserveUpdated(path, fmt.Sprintf("prov/model-%d", i)); err != nil {
select {
case metaErrCh <- err:
default:
}
return
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
last := int64(0)
for {
select {
case <-stop:
return
default:
}
if meta, ok, err := LoadBranchMeta(path); err == nil && ok {
if meta.Revision < last {
regressed.Store(true)
return
}
last = meta.Revision
}
}
}()
for i := 1; i <= saves; i++ {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot %d under meta-writer hammer: %v", i, err)
}
}
stopAll()
wg.Wait()
close(metaErrCh)
for err := range metaErrCh {
t.Fatalf("meta writer error: %v", err)
}
if regressed.Load() {
t.Fatal("meta revision regressed while meta-only writers raced saves")
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
}
if want := int64(saves + 1); meta.Revision != want {
t.Fatalf("final revision = %d, want %d", meta.Revision, want)
}
digest, err := digestSessionMessages(s.Snapshot())
if err != nil {
t.Fatalf("digest final content: %v", err)
}
if meta.ContentDigest != digestString(digest) {
t.Fatalf("final content digest = %q, want %q", meta.ContentDigest, digestString(digest))
}
if meta.CustomTitle == "" || meta.Model == "" {
t.Fatalf("meta-only fields lost under hammer: %+v", meta)
}
assertNoRecoveryBranches(t, path)
}
// A saver that blocks on the save lock must persist whatever the session
// holds when it finally enters the critical section, not a stale capture from
// when it was scheduled — the out-of-order landing that used to surface as
// "session changed on disk" adoptions between the turn-end snapshot, periodic
// autosave, and shutdown snapshot.
func TestSaveSnapshotCapturesContentUnderSaveLock(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot seed: %v", err)
}
unlock := lockSessionSavePath(path)
done := make(chan error, 1)
go func() { done <- s.SaveSnapshot(path) }()
// Let the saver reach the save lock, then grow the session while it waits.
time.Sleep(50 * time.Millisecond)
s.Add(provider.Message{Role: provider.RoleUser, Content: "added while saver waited"})
unlock()
if err := <-done; err != nil {
t.Fatalf("SaveSnapshot after lock release: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 3 {
t.Fatalf("saved %d messages, want 3 (snapshot must be captured under the save lock)", got)
}
assertNoRecoveryBranches(t, path)
}
// Multiple in-process savers of one session (turn-end snapshot, periodic
// autosave, shutdown snapshot) must never conflict with each other: the
// snapshot is captured under the save lock, so a stale pre-lock capture can
// no longer land after a newer one.
func TestConcurrentSnapshotSaversNeverConflict(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "turn 0"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot seed: %v", err)
}
const adds = 30
stop := make(chan struct{})
errCh := make(chan error, 64)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
if err := s.SaveSnapshot(path); err != nil {
select {
case errCh <- err:
default:
}
}
select {
case <-stop:
return
default:
}
}
}()
}
for i := 1; i <= adds; i++ {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d", i)})
time.Sleep(time.Millisecond)
}
close(stop)
wg.Wait()
close(errCh)
for err := range errCh {
if errors.Is(err, ErrSessionSnapshotConflict) {
t.Fatalf("concurrent snapshot savers conflicted: %v", err)
}
t.Errorf("concurrent snapshot saver error: %v", err)
}
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("final SaveSnapshot: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got, want := len(loaded.Messages), adds+2; got != want {
t.Fatalf("final message count = %d, want %d", got, want)
}
assertNoRecoveryBranches(t, path)
}
// A save that lands its transcript bytes and then fails to record the
// revision (fail-closed record, or a crash between the two writes) leaves the
// ledger describing older content. The next save of the same snapshot takes
// the up-to-date path and must heal the ledger — record the revision and
// digest the interrupted save deferred — instead of skipping it forever.
func TestSameContentSaveHealsStaleLedgerDigest(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
metaPath := BranchMetaPath(path)
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
staleMeta, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read base meta: %v", err)
}
// Land new content, then rewind the sidecar to the pre-append ledger:
// the exact on-disk aftermath of an append whose revision record failed.
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot append: %v", err)
}
if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
t.Fatalf("rewind meta: %v", err)
}
// Any runtime resuming this file now pairs the landed transcript with the
// stale revision — the post-crash shape.
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession on stale ledger: %v", err)
}
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("same-content SaveSnapshot: %v", err)
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
}
if meta.Revision != 2 {
t.Fatalf("healed revision = %d, want 2", meta.Revision)
}
digest, err := digestSessionMessages(loaded.Snapshot())
if err != nil {
t.Fatalf("digest current messages: %v", err)
}
if meta.ContentDigest != digestString(digest) {
t.Fatalf("healed digest = %s, want %s", meta.ContentDigest, digestString(digest))
}
assertNoRecoveryBranches(t, path)
// The healed baseline keeps working: the next append saves cleanly.
loaded.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("append after heal: %v", err)
}
after, _, err := LoadBranchMeta(path)
if err != nil {
t.Fatalf("LoadBranchMeta after append: %v", err)
}
if after.Revision != 3 {
t.Fatalf("revision after post-heal append = %d, want 3", after.Revision)
}
assertNoRecoveryBranches(t, path)
}
// The surviving in-process saver — whose baseline never advanced because the
// failed save returned before markPersisted — heals through the same
// up-to-date path on its autosave retry of the identical snapshot.
func TestSameContentRetryHealsLedgerForSurvivingSaver(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
metaPath := BranchMetaPath(path)
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
staleMeta, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read base meta: %v", err)
}
baseline := s.persistState(path)
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot append: %v", err)
}
// Rewind the sidecar and the in-memory baseline to the mid-save failure
// state: bytes landed, record failed, markPersisted never ran.
if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
t.Fatalf("rewind meta: %v", err)
}
s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true, 0)
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("autosave retry: %v", err)
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after heal ok=%v err=%v", ok, err)
}
if meta.Revision != 2 {
t.Fatalf("healed revision = %d, want 2", meta.Revision)
}
assertNoRecoveryBranches(t, path)
}
// A legacy sidecar that predates content digests is not a stale ledger: the
// up-to-date path must leave it untouched rather than bump a revision other
// runtimes still hold as their baseline.
func TestUpToDateSaveLeavesDigestlessLegacyMetaAlone(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
metaPath := BranchMetaPath(path)
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot base: %v", err)
}
// Strip the digest by editing the raw sidecar: the save helpers back-fill
// an empty digest from the existing meta, exactly like real legacy files
// acquired one only through a content-bearing save.
raw, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read meta: %v", err)
}
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("decode meta: %v", err)
}
delete(fields, "content_digest")
stripped, err := json.Marshal(fields)
if err != nil {
t.Fatalf("encode meta: %v", err)
}
if err := os.WriteFile(metaPath, stripped, 0o644); err != nil {
t.Fatalf("write legacy meta: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession legacy meta: %v", err)
}
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("same-content SaveSnapshot: %v", err)
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
if meta.Revision != 1 {
t.Fatalf("legacy revision = %d, want 1 (no gratuitous bump)", meta.Revision)
}
if meta.ContentDigest != "" {
t.Fatalf("legacy digest = %q, want empty (untouched)", meta.ContentDigest)
}
assertNoRecoveryBranches(t, path)
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more