Compare commits

...
118 Commits
Author SHA1 Message Date
SivanCola 7d68fb1a93 Merge pull request #6182 from SivanCola/fix/secret-output-redaction
Redact secret-bearing tool output / 脱敏工具输出中的密钥
2026-07-08 18:11:19 +08:00
SivanCola 5ea5593f40 style(doctor): satisfy staticcheck S1008 in event-log scan 2026-07-08 18:00:30 +08:00
SivanCola bdeeb644c4 fix(security): close review gaps in cleanup coverage, env filtering, config persistence, auth schemes
Four review findings on the redaction line, each pinned by a repro:

- doctor redact-sessions now scans every event-log record, not just the
  replayed view: a later replace event supersedes but does not erase
  earlier records, so a raw key could survive in a stale event while
  the current messages were already clean. Any record needing masking
  (or an undecodable tail, which may hold torn secret bytes) triggers
  the compacting rewrite; the clean replayed view is what survives.

- filter_subprocess_env now reaches every spawn path: environment
  probes set the filtered env even when the probe declares no extra
  variables, the bash and MCP stdio login-shell PATH probes stop
  inheriting the full environment, and the sweep covers ripgrep,
  project indexing, plugin-package git, attachment converters, shell
  viability probes, ccswitch sqlite, and OS notifiers. Deliberately
  exempt: the user's `!` passthrough shell (the documented escape
  hatch for credentialed commands), MCP-manager editor/open launchers
  (the user's own applications), and Windows kill/system utilities.

- RenderTOMLForScope now emits [secrets] for the user scope, so saving
  the config no longer drops the user's security toggles; the project
  scope still omits the section LoadForRoot would ignore.

- The key/value pattern captures Basic/Digest/Negotiate/NTLM/Token/Bot
  auth schemes alongside Bearer: an uncaptured scheme word was itself
  swallowed as the value, leaving the real credential in the clear
  right behind the [redacted] marker. The scheme is preserved in
  output and the replacement stays idempotent.
2026-07-08 17:52:20 +08:00
SivanCola 6c9671aa57 fix(doctor): decode session artifacts before redacting, not raw bytes
Masking the raw encoded bytes of JSONL/JSON artifacts corrupts them: a
quoted secret is stored as PASSWORD=\"...\" on disk, and the key/value
pattern's value group consumes the escape's backslash, so the rewritten
line terminates the JSON string early. The transcript stops decoding
(LoadSession hard-fails), an event log truncates at the broken record
and the next save compacts the loss in permanently — and the secret
itself was left in the clear outside the match. Already-redacted
healthy stores hit the same corruption on rescan, because the masked
form re-triggers the escape-eating match in its encoded shape.

Redact decoded values instead, per format:

- Transcripts, guardian sessions, and event logs go through
  agent.LoadSession + Session.Save: the save path re-runs
  RedactMessages on decoded content, folds the event log into a single
  redacted replace event, refreshes the anchor and event index, and
  records the revision under the same cross-process locks live
  sessions use — digests, the CAS ledger, and replay stay coherent,
  and reruns are no-ops because Redact is idempotent.
- Branch metadata masks its free-text fields (preview, titles, goal,
  recovery reason) through the typed LoadBranchMeta/SaveBranchMeta
  pair under LockSessionMetaPath.
- Goal state and job .json sidecars decode with UseNumber, mask every
  string in the tree, and re-encode; unparseable files are reported
  and left untouched instead of risked with a raw rewrite.
- Only plain-text job .log files keep raw-byte redaction.

Regression tests pin the two failure shapes: a quoted-secret historical
transcript must stay loadable with the secret masked, and a healthy
already-redacted store must come through byte-identical.
2026-07-08 17:20:15 +08:00
SivanCola 030a4d5363 Merge pull request #6183 from esengine/codex/notice-preview-real-page
Add real-page notice preview mock
2026-07-08 17:13:00 +08:00
SivanCola 9087238c7a Trigger PR CI 2026-07-08 17:06:58 +08:00
SivanCola 704b15f4d2 Merge pull request #6174 from esengine/codex/compact-chat-notices-i18n
fix(desktop): compact backend notices in chat
2026-07-08 15:31:31 +08:00
SivanCola 38ec4093fe fix(security): add historical session redaction cleanup 2026-07-08 14:35:03 +08:00
SivanCola 079293f0d1 Fix notice preview DOM nesting to match Transcript markup
Render notice-line__text as a div (details is flow content, invalid
inside span) and use the same details > div structure as NoticeCard so
the preview reflects the real notice markup.
2026-07-08 14:30:58 +08:00
SivanCola 4c5e5a9572 Preserve planner notice detail in history and fix notice DOM nesting
Planner-sourced notices recorded through recordPlannerDisplayEvent
dropped Detail, so the expandable diagnostics vanished after a reload
even though the live transcript showed them; persist Detail the same
way the main history path (app.go notice restore) already does, with a
focused regression test.

Also render notice-line__text as a div: <details> is flow content and
is not valid inside <span>; the element is a flex item so layout is
unchanged.
2026-07-08 14:29:21 +08:00
SivanCola a22eba4a86 Merge pull request #6168 from SivanCola/fix/hide-legacy-recovery-topics
Hide legacy recovery topics from desktop sidebar / 隐藏旧恢复分支侧栏话题
2026-07-08 14:25:35 +08:00
SivanCola 99c1539142 fix(security): gate env/path protections behind [secrets] config, fix PWD and clone bugs
Split the credential-protection layers by blast radius:

- Durable surfaces (session transcripts, job artifacts) always redact.
- Live tool output redaction stays default-on but honors [secrets]
  redact_tool_output for fixture-heavy repos.
- Subprocess env filtering and the sensitive-path read denylist become
  explicit opt-ins (filter_subprocess_env, protect_sensitive_files,
  both default off): stripping GH_TOKEN/NPM_TOKEN broke gh, HTTPS git
  push, and npm publish; hiding .env broke legitimate edit workflows.

[secrets] is user-global: project reasonix.toml values are discarded on
load so a cloned repo cannot disable redaction or flip on the
workflow-breaking protections. The captured global section deep-copies
its *bool so the project-file TOML decode cannot write through it.

Fix the PWD false positive: bare "pwd" now requires a leading [_-]
separator (DB_PWD masks, POSIX PWD/OLDPWD pass through), keeping every
subprocess's working-directory context intact.

Fix RedactMessage writing through shared ToolCalls/MemoryCitations
backing arrays: the save path hands in live session messages, and
in-place masking would silently rewrite model-visible history
mid-conversation and churn the prompt cache. Clone before masking.

Fix SaveRecoveryBranch bypassing redaction: recovery branches are
durable transcripts, and comparing a raw snapshot against the
already-redacted on-disk content would misread coverage as divergence
and fork a bogus recovery branch. Document the digest-consistency
argument (Redact is deterministic and idempotent, so load/save cycles
are byte-stable) and pin it with a regression test.
2026-07-08 13:42:25 +08:00
SivanCola 4524016932 Guard notice preview mock behind browser-only scenario param
Reuse browserMockScenarioParam so the notice preview panel cannot
activate inside the Wails shell (the helper returns empty when
window.runtime is present), matching the existing guidance-mock guard.
Drop the notice preview mock history: the preview panel replaces the
transcript, so that history was never rendered.
2026-07-08 12:32:06 +08:00
SivanCola 10e637bce6 feat(frontend): sweep recovery copies to trash from history
The trash view could already bulk-purge recovery copies, but copies still
sitting in the session list had to be trashed one by one — painful for
users who accumulated hundreds from the legacy conflict loop. The history
view now offers the same one-click sweep, moving every idle recovery copy
to the trash; copies that are current or open in a tab are skipped because
an adopted copy is someone's live conversation.

Also folds the redundant onPurgeRecoveryCopies prop into the view-aware
sweep handler (trash purges via onPurgeAll, history trashes via the new
onDeleteMany), and covers both flows plus the skip rule with a component
test that runs in the standard tsx harness.
2026-07-08 12:24:12 +08:00
SivanCola 0af75a1939 Add notice preview mock page 2026-07-08 12:15:03 +08:00
SivanCola 344895e496 fix(security): redact secret-bearing tool output 2026-07-08 12:13:50 +08:00
SivanCola 0c2efcd5ae Merge pull request #6176 from SivanCola/fix/tui-session-recovery-lease
Fix TUI session recovery lease tracking / 修复 TUI 会话恢复 lease 跟随
2026-07-08 12:08:39 +08:00
SivanCola 3cb37b5c9c fix(cli): move lease before rebuild binds the recovery path
The /model, /effort, and skills-refresh rebuild paths snapshot (which can
retarget the controller to a recovery branch), then hand prevPath to the
async buildController, whose AdoptHistory resumes that path for writing.
The lease only followed in the modelSwitchMsg handler after the build
returned, leaving a window where the rebuilt controller writes a recovery
path the TUI does not guard. Rebind the lease to prevPath before firing
the build, and abort the switch on refusal instead of binding an unguarded
writer. Matches the lease-before-bind order /resume and /switch already
follow.
2026-07-08 11:56:19 +08:00
SivanCola eb9e3e8e0f Merge pull request #6177 from esengine/codex/perf-lag-diagnostics
fix(frontend): Reduce startup performance lag false positives
2026-07-08 11:47:12 +08:00
SivanCola d0cc98d9d3 test(frontend): cover bridge timing breadcrumbs 2026-07-08 11:40:15 +08:00
SivanCola c3ef4bfc92 fix(cli): follow lease on completion listing and failed model switch
Branch listing during /switch tab-completion snapshots the session, which
can retarget the controller to a recovery branch without any switch
running; the lease now follows immediately instead of guarding the stale
original path. Likewise, when a model/effort/skill rebuild fails after the
pre-switch snapshot already retargeted the kept controller, the error
branch now re-points the lease at the recovery path instead of leaving the
stale binding until the next lease-following operation.
2026-07-08 11:39:29 +08:00
SivanCola 7cca6e9770 fix(frontend): reduce startup performance lag false positives 2026-07-08 11:33:40 +08:00
SivanCola 431dddb1ef fix(desktop): compact backend notices in chat 2026-07-08 11:30:26 +08:00
SivanCola 8178abc0bf fix(cli): keep TUI leases on recovered sessions 2026-07-08 11:17:04 +08:00
SivanCola 009a2359cb fix: surface recovery copies in history and trash 2026-07-08 11:16:24 +08:00
Sivan 972f052277 fix(desktop): hide legacy recovery topics 2026-07-08 10:27:42 +08:00
SivanCola f501f3dba0 Merge pull request #6164 from SivanCola/feature/composer-run-dock
Dock run state into the composer card and add prompt-card previews / 运行态内嵌输入卡与审批·提问卡预览行
2026-07-08 09:54:04 +08:00
Sivan 80cf5274bf test(frontend): mock canvas in settings snapshot test 2026-07-08 09:45:59 +08:00
SivanCola 4317a6176b Merge pull request #6165 from SivanCola/fix/bounded-slugs-and-tail-reads
Bound lossy slugs and tail-read append-only logs / 限长有损 slug 并尾读追加日志
2026-07-08 09:43:32 +08:00
SivanCola 3716462392 Fix workspace slug test on Windows 2026-07-08 09:33:44 +08:00
Sivan 2f4a6995f1 Keep --composer-height in logical space across all resize writers
Review found the strip compensation baked into the React render path
only: composerCardStyle wrote height+30px while the live drag updater
and drag-start measurement wrote raw logical heights to the same CSS
variable. Dragging a resized composer during a running turn briefly
shrank the card by the strip height (re-clipping the meta row) until
release re-added the compensation — a visible jump.

Make the variable single-meaning: --composer-height always carries the
user's logical height from every writer (render, live drag, keyboard),
and the strip reservation moves into a separate
--composer-run-strip-reserved variable consumed by a CSS calc on
.composer-card--resized. DOM-based fallback measurements subtract the
strip so auto-height cards convert to the same space.

Regression covered in composer-run-strip: drag start/release during a
running turn stays in logical-height space, the reservation survives
the drag, and the idle card releases it.
2026-07-08 03:54:57 +08:00
Sivan 1401a07a49 Bound the stats filename stem so the hash suffix cannot overflow
Review finding: statsPath's new "-<hash>" suffix pushed previously
writable long slugs (236-244 bytes, which fit as "<slug>.stats.json")
past the 255-byte filename component limit, so stats telemetry for
long server names regressed to ENAMETOOLONG on every write.

Budget the stem at 255 minus the suffix length before appending the
hash. Truncating the slug is safe because the hash of the raw name
carries the uniqueness.

Regression test: a 240-byte name produces a <=255-byte component,
records and reads back end-to-end, and two long names sharing the
truncated stem keep distinct files. Fails at 260 bytes on the previous
commit.
2026-07-08 03:54:51 +08:00
Sivan d39a7694d3 Give colliding plugin stats files per-server hashed paths
Review finding on the previous commit: the ownership check alone kept
colliding servers ("foo.bar"/"foo-bar") writing one shared file, so
interleaved startups alternately reset each other's window — neither
could accumulate maxSamples and a chronically slow server might never
auto-demote. Worse, a legacy nameless file's samples were still trusted
by Recommend, so one old slow file could demote the colliding healthy
server before its first write.

statsPath now embeds an FNV-1a hash of the raw server name, giving
every server its own file. The pre-hash path is read-only for
migration: a legacy file whose recorded Name matches is carried into
the hashed path on the next write; a nameless legacy file has no
provable owner, so it never feeds Recommend and never seeds a window.
Nameless files at the hashed path remain trusted — the hash pins them
to exactly one name.

Regression tests: interleaved colliding names accumulate independent
windows and demote independently; a named legacy file migrates with
history intact; a nameless legacy collision demotes nobody and is not
inherited.
2026-07-08 03:38:43 +08:00
Sivan fb1840f22d Preview each approval action's consequence on the tool approval card
The allow-once / allow-session / always-allow / deny pills carry real
semantic differences (scope, persistence) that the labels alone do not
explain. Reuse the ask card's quick-pick pattern: a consequence row
under the pills previews what the hovered or arrow-selected action will
do, with a native title fallback on each pill. The subject block stays
default-visible and untouched — what you approve must remain fully
inspectable.

The tool actions are now a descriptor list driving both the buttons and
the preview, replacing the hand-rolled branch JSX. Also map
exit_plan_mode to a friendly label ("Plan approval") — the composer run
strip previously showed the raw tool name while a plan was waiting.
2026-07-08 03:33:35 +08:00
Sivan 8652137e92 Add a focus-following detail preview row to the ask card
The multi-column grid truncates option descriptions to one line, and
narrower columns made the loss worse — a cut-off description is the
one piece of information the user needs before answering. Add a
quick-pick-style detail row pinned under the options: it previews the
full description of the hovered/focused option, falling back to the
latest selected option and then the first described option so it
never blanks. Each option also carries a native title tooltip as a
redundant fallback.

PromptShelf gains a `note` slot (between actions and quick actions)
and PromptAction gains `title`/`onHoverChange`; both are opt-in and
inert for existing callers (approval card, clear-context card).
2026-07-08 03:33:35 +08:00
Sivan 75c858e543 Compact the ask card: multi-column grid and inline submit
The ask card used full-width single-column option cards (42px each)
with full-width back/submit rows, consuming ~400px+ of viewport. This
was the compact shelf's grid being overridden by the ask-specific
1fr rule at higher specificity.

Switch to a responsive multi-column grid (auto-fill, minmax 200px)
with slightly shorter items (36px), raise the scroll cap to
min(220px, 30vh), and move back/submit into a right-aligned pill row
with a subtle border-top separator. Four options now render in two
rows at ~93px total; the overall card height drops from ~400px to
~230px.
2026-07-08 03:33:35 +08:00
Sivan 9c058ef04f Anchor run strip as in-flow row with resized-card compensation
The absolute-positioned tab overlapped the guidance shelf, context
cards, and pasted blocks when those sat above the card. Switch to an
in-flow row: auto-height cards grow naturally, and user-resized cards
get COMPOSER_RUN_STRIP_RESERVED (30px) added to --composer-height via
inline style while the strip is visible — so the meta row stays fully
visible and reverts when the turn ends.
2026-07-08 03:33:35 +08:00
Sivan a665c88192 Keep the approval bar usable while its own prompt is pending
The composer is disabled while a tool approval is pending, which also
disabled the ask/auto/yolo bar — making the controller's designed
mode-switch semantics unreachable from the UI: SetToolApprovalMode
drains pending approvals the relaxed posture allows (fresh-human tools
are never drained, approval.go drainLocked). Scope the disable so the
approval bar stays active exactly when the pending approval itself is
the reason: relaxing the mode now resolves drainable requests in place,
and for surviving fresh-human requests the approval card's
mode-switch hint explains that an explicit decision is still needed.

Verified live against the dev instance: a pending bash approval
auto-resolves when switching ask -> auto, the run strip returns from
waiting to streaming, and the composer stays disabled for
non-approval reasons (covered by composer-run-strip assertions).
2026-07-08 03:33:35 +08:00
Sivan fa646b75c9 Anchor run strip as zero-height tab on the composer card
The in-flow strip added ~30px to the card's content height, which a
user-resized card (fixed --composer-height, minimum 104px) could not
absorb: the meta row overflowed the card bottom and was clipped behind
the app status bar. Position the strip absolutely on the card's top
edge instead — it no longer participates in height math (resized cards
keep their exact height) and turn start/finish causes zero layout
shift. The strip is non-interactive (pointer-events: none) so the
resize handle underneath keeps working.
2026-07-08 03:33:35 +08:00
Sivan 2d489f5c5f Dock run state into the composer card
Replace the floating run-status pill with a run strip inside the
composer card, so starting or finishing a turn no longer shifts the
surrounding layout, and give stop a fixed home next to the send button
while a turn runs (send switches to a queue-guidance icon).

The strip is a small state machine: retry > waiting-approval >
waiting-ask > streaming. While a tool approval or ask question is
blocked on the user, the strip says so (with the pending tool label)
instead of ticking whimsical "working" seconds, and the card hands the
running accent off to the prompt card above it.

Accessibility: the per-second ticker was inside an aria-live region and
re-announced every second; it is now aria-hidden, with a separate
sr-only status region that announces only stable state transitions.

The approval card also gains a hint when the approval mode is relaxed
(ask -> auto/yolo) while a request is pending, since switching modes
does not auto-resolve the pending prompt.

Verified: tsc --noEmit, tsc -p tsconfig.test.json, check:css, new
composer-run-strip test (16 assertions), and the full frontend test
chain all pass.
2026-07-08 03:33:35 +08:00
merge-order-check 4098c4d98f Bound lossy slugs and tail-read append-only logs
Four related unbounded-growth/lossy-slug defects, all with old-data
compatibility preserved:

1. AutoResearch direction fingerprints: slugify truncates to 56 chars
   and drops non-ASCII runes, so directions sharing a long ASCII prefix
   (or differing only in CJK text) collapsed to one fingerprint and
   wrongly inflated StaleCount/PivotCount toward forced pivots on long
   tasks. directionFingerprint appends an FNV-1a hash of the normalized
   summary only when slugify was lossy; short ASCII summaries keep the
   bare slug so fingerprints recorded by older versions still match,
   and legacy directions_tried.json entries are matched by recomputing
   from their stored summary and migrated in place.

2. AutoResearch bounded reads: Findings/Heartbeats scanned the whole
   JSONL file then truncated, a per-turn cost that grows for the life
   of a task (Summary, runtime block, and LastHeartbeat all hit this
   every turn). Bounded requests now tail-read backward in 64KiB
   chunks; limit 0 keeps the full scan for accepted-evidence lookups.

3. Workspace path slugs (config.WorkspaceSlug, memory.StoreFor): deep
   workspace paths flattened to a single component exceeding the
   255-byte filename limit, breaking project sessions, MemoryCompiler
   state, and the auto-memory store with ENAMETOOLONG. Components are
   now bounded with a hash suffix; any path short enough to have ever
   worked is returned byte-identical, so existing directories resolve.

4. Memory name slugs and plugin stats: slug(m.Name)+".md" hit the same
   filename limit for names distilled from long titles (now bounded,
   short names unchanged); plugin startup-stats files share a lossy
   filename slug across servers ("foo.bar"/"foo-bar"), letting one
   server's slow samples demote another. Stats now record the owning
   server name — mismatched files are treated as no-history and taken
   over fresh; legacy files without the field are adopted on the next
   write without losing samples (additive field, no version bump).
2026-07-08 03:09:58 +08:00
SivanCola 07c65c2222 Merge pull request #6162 from SivanCola/feature/memory-v5-memory-candidates
Surface stable Memory v5 learnings as memory candidates in settings / 将 Memory v5 稳定学习成果提炼为设置页记忆候选
2026-07-08 02:10:11 +08:00
SivanCola 8357312237 Merge pull request #6158 from SivanCola/fix/project-readd-self-heal
fix(desktop): self-heal project re-add roots
2026-07-08 02:07:42 +08:00
merge-order-check 0403d1d1a4 Preserve generated candidate names through the accept path
Review finding: AcceptMemorySuggestion re-derived the name with
suggestionName(in.Name, ...), whose asciiSlug pass truncates to 56
chars and strips the uniqueness hash suffix. Two candidates sharing a
long ASCII prefix got distinct generated Name/ID but collapsed to the
same file at save time, so the later save overwrote the earlier one.

Accept now keeps a well-formed generated slug verbatim
(isWellFormedSlug: lowercase ASCII/digit runs separated by single
dashes, up to 128 chars — room for the hash suffix beyond asciiSlug's
56-char cap). Malformed or empty names still fall back to deriving
from the description as before, and memory.Store.Save's own slug pass
remains the final validator (it cleans but never truncates).

Regression test: two >56-char common-prefix compiler patterns accept
into two distinct memory files; fails with "saved memories = 1" before
the fix.
2026-07-08 01:56:31 +08:00
merge-order-check 824f232478 fix(desktop): replace remaining normalizeProjectRoot equality with sameProjectRoot
Five project-root comparisons still used
normalizeProjectRoot(a) == normalizeProjectRoot(b), which does not
fold case on Windows — so a re-add under a different case spelling
could miss the existing session/tab/migration match:

- topicSessionMatchMatchesTarget (session meta vs registry root)
- tabMatchesTopicTarget (tab reuse for same topic)
- tabInWorkspace (tab-in-workspace predicate)
- reconcileTabWithLiveBinding (controller root vs tab root guard)
- legacySessionScopeMatchesMigrationTarget (migration scope check)

Switch all five to sameProjectRoot and add a Windows-only regression
test that plants a session with the original-case root in its meta,
re-registers the project under a flipped-case root, and verifies
findTopicSessionForTarget still resolves the original session.
2026-07-08 01:44:35 +08:00
merge-order-check 14801d3118 test(memory): expect folded slug case on Windows
config.WorkspaceSlug now folds case on Windows so equivalent spellings
of one workspace share a slug; TestStoreForSlug asserted the unfolded
spelling and failed on the windows-latest leg.
2026-07-08 01:44:35 +08:00
merge-order-check 64253776a8 fix(config,desktop): fold Windows case in workspace slugs and sync tab root spellings
WorkspaceSlug fed the raw absolute path into the slug, so on Windows
C:\Foo and c:\foo produced two projects/<slug> state directories and
the same workspace's session list and memory-compiler state split.
Fold case there (matching agent.CanonicalSessionPath's key form) and
route internal/memory's duplicate slugify through it. Existing
mixed-case slug directories keep working: NTFS resolves directory
names case-insensitively, so the folded slug opens the same directory
and no migration is needed. Unix slugs are unchanged - case-different
paths there are genuinely different directories.

The frontend compares tab.workspaceRoot against tree node roots with
exact string equality, so a registry re-spell (addProject adopting the
caller's spelling) could strand open tabs on the old spelling and
break ownership highlighting and session filters. Sync open project
tabs to the registry's canonical spelling after every registry write
(registerProjectRoot / rename / color paths), persist the tab
snapshot, and emit project-tree:changed so the frontend refreshes -
keeping one string form as the single source of truth instead of
teaching the frontend platform-aware path semantics.

Also fold the startup-migration and recovery-GC seen maps
(desktopMCPMigrationRoots, recoverLegacyProjectSidebarRoots,
recoveryGCDirs) with projectRootKey so case-split roots are not
scanned or appended twice, and cover the tab re-spell sync with a
Windows-only regression test on the desktop-windows CI leg.
2026-07-08 01:44:35 +08:00
merge-order-check 2a3543adca fix(desktop): extend root equivalence to runtime lookups and lock the Windows contract
The registry-side matcher still left same-shape exact comparisons on
paths that cross spelling sources:

- fold case in topicSummaryKey (matching agent.CanonicalSessionPath's
  key form) so sidebar topic runtime status never splits across the
  tab, registry, and session-meta root spellings
- match roots with sameProjectRoot in blank-tab reuse, open-topic
  collection, session-binding workspace notices, topic prepend/repair,
  indexed blank-topic reuse, and ListWorkspaces' Current flag
- keep the global sidebar token out of path comparisons in
  normalizeSidebarOrder

The re-add regression tests spelled the equivalent root with
filepath.Join(root, "."), which Join cleans back to the identical
string, so they also passed on the unpatched base. Spell the dot
segment by hand, and add Windows-only case-folding tests (*OnWindows)
plus a windows-latest desktop CI leg to run them - the ubuntu leg can
never reach the EqualFold branch.
2026-07-08 01:44:35 +08:00
merge-order-check cd9023ecf8 fix(desktop): self-heal project re-add roots 2026-07-08 01:44:35 +08:00
merge-order-check 78133b1a26 Preserve backward-compatible names for ASCII-safe history candidates
For history-derived candidates whose asciiSlug is non-empty and shorter
than the 56-char truncation boundary, return the plain slug without a
hash suffix. This keeps names identical to the old format, so an
already-accepted memory under the old name is not duplicated after
upgrade.

Hash suffix is still always appended for:
- CJK-only statements (empty slug → collision guaranteed)
- Statements whose slug hit the 56-char truncation boundary
  (collision likely with long common-prefix inputs)
- Compiler candidates (inherently homogeneous: same tool + similar
  error prefix)

Compiler candidates retain their own compilerCandidateName() which
always appends hash, since pattern homogeneity makes slug collisions
the norm, not the exception.

New test: TestHistoryEnglishCandidateNameBackwardCompat asserts English
candidates produce the same Name as old code (no hash suffix).
2026-07-08 01:40:47 +08:00
merge-order-check 8b0492525a Unify stable name generation for all suggestion candidates
Same-shape sweep: history-derived memory candidates from
suggestMemories used ordinal-suffixed fallback names
("memory-candidate-1", ...) when asciiSlug returned empty for pure-CJK
statements. Two CJK statements with different content but identical
empty slugs would collide on Name/ID, causing the same overwrite and
frontend state cross-wire issue found in the compiler candidates.

Extract stableSuggestionName(source, fallback) into
memory_suggestions.go: it appends an FNV-1a hash of the full source
text to the asciiSlug base, keeping names unique per content and stable
across refreshes. Both suggestMemories and suggestCompilerMemories now
use this single helper; the standalone compilerCandidateName is removed.

Regression tests: pure-CJK history statements produce distinct
Name/ID and persist as separate memory files; IDs are stable across
suggestion refreshes.
2026-07-08 01:21:41 +08:00
merge-order-check 43b147e4f2 Make compiler candidate names unique with a pattern hash suffix
Review finding: candidate Name/ID came from asciiSlug of the pattern,
which drops non-ASCII runes and truncates to 56 chars. Patterns
differing only in CJK error text (or sharing a long English prefix)
collided: colliding IDs cross-wire the frontend's React keys and
accepted-state map, and colliding Names make Store.Save overwrite the
earlier accepted memory.

Append an FNV-1a hash of the full pattern to the slug. The hash is
derived from the pattern itself, so IDs stay stable across suggestion
refreshes and the accepted-state map remains valid.

Regression tests cover CJK-only-diff patterns producing distinct
Name/ID and two distinct persisted memories (fails against the
previous commit), and ID stability across refreshes.
2026-07-08 01:08:35 +08:00
SivanCola 30ecaf4354 Merge pull request #6160 from SivanCola/feature/memory-v5-outcome-signals
Upgrade Memory v5 outcome signals, injected split, and learnings export / 升级 Memory v5 成败信号、注入分桶统计与 learnings 导出
2026-07-08 01:06:35 +08:00
merge-order-check 9dce3d05fc Consume outcome ref on every turn and clear it on session switch
Review finding: the pending lastCompilerOutcome ref was only consumed
when the Memory v5 runtime was non-nil, so /memory-v5 off froze it.
After re-enabling, a corrective first input ("still broken") could
retroactively downgrade a success trace from many turns ago, violating
the one-shot contract and corrupting strategy counters.

Fix at the consumption semantics rather than the toggle: every
non-synthetic turn start now consumes the ref, runtime present or not,
so it can never outlive the turn that follows it. This also keeps the
correct behavior when off->on happens with no intervening turn.

Same-shape sweep found one more leak: SetSession (switch/resume) kept
the ref alive across conversations, letting the new session's first
corrective input revise the old session's trace. Session switches now
clear the ref. Both ref accesses are now guarded by memoryCompilerMu,
matching how the neighboring compiler fields are protected.

Regression tests cover the reviewer's off -> intervening turn -> on ->
corrective feedback scenario and the session-switch scenario; both
fail against the previous commit.
2026-07-08 00:48:27 +08:00
merge-order-check 8be5af9f6f Surface stable Memory v5 learnings as memory candidates in settings
Memory v5 already learns repeated failure patterns per workspace
(NoisyRefs), but they were buried in state.json. This bridges the
stable ones into the settings Memory page's existing suggestions flow:

- memorycompiler.StableNoisePatterns returns failure patterns that
  recurred across a minimum number of separate turns, ordered by
  frequency. Read-only over compiler state.
- suggestCompilerMemories converts them into project-typed memory
  candidates with provenance (reason + trace evidence), deduplicated
  against history-derived suggestions and existing saved memories, and
  appends them to MemorySuggestionsView.Memories.
- Candidates persist only through the regular AcceptMemorySuggestion
  confirmation flow; once accepted, the pattern stops being suggested.

No frontend changes needed: candidates render through the existing
suggestion card, which already shows reason and evidence. No
provider-visible behavior changes.
2026-07-08 00:29:15 +08:00
merge-order-check c9ea166b04 Upgrade Memory v5 outcome signals, injected split, and learnings export
- Treat user-cancelled turns as "aborted": still traced for
  observability, but excluded from strategy counters and mutation
  evaluation so cancellations stop poisoning success rates.
- Recognize verification-shaped bash commands (go test/vet, pnpm test,
  pytest, tsc, ...) as a stronger outcome signal than the last-tool
  heuristic: a failing final verification caps the turn at
  partial_success; a passing one confirms success unless a later tool
  errored after it.
- Retroactively downgrade a recorded success when the user's immediate
  follow-up reports the result wrong. One-shot per turn, matched only
  against the head of the message, and a no-op when the referenced
  trace is not in this runtime's trace log.
- Persist per-turn injection state on runtime/learning traces and split
  strategy counters into injected vs observed, so compact-mode lift is
  measurable instead of assumed.
- Add "/memory-v5 learnings" to the CLI TUI and the shared control
  slash surface: strategy success rates with the injected/observed
  split, repeated noise patterns, and recent improvements/findings.

No provider-visible behavior changes: the observe default, contract
format, tool schemas, and system prompt are untouched.
2026-07-08 00:07:14 +08:00
SivanCola 5992b2ef25 Merge pull request #6153 from SivanCola/fix/compact-tool-error-details
Compact tool error details / 折叠工具错误详情
2026-07-07 21:22:33 +08:00
merge-order-check bc556ce363 Merge main-v2 into fix/compact-tool-error-details 2026-07-07 21:15:57 +08:00
SivanCola b98d4277c5 Merge pull request #6143 from SivanCola/fix/recovery-banner-acknowledge
Quiet recovery-copy notifications / 移除冲突副本横幅、toast 与转写通知
2026-07-07 21:06:05 +08:00
merge-order-check c009c1bfdf fix(frontend): quiet background lifecycle notices 2026-07-07 20:57:04 +08:00
merge-order-check 7dd84c5d25 fix(frontend): quiet recovery lifecycle prompts 2026-07-07 20:40:11 +08:00
SivanCola 7ac91a41ec test(cli): avoid blocking node stdin in statusline test 2026-07-07 20:09:03 +08:00
SivanCola 53bd6bf901 test(cli): give windows statusline node eval more time 2026-07-07 20:02:24 +08:00
SivanCola d743a99ae8 fix(cli): let statusline runner repair raw node eval commands 2026-07-07 19:53:18 +08:00
SivanCola 3cfc430341 Merge remote-tracking branch 'origin/main-v2' into fix/recovery-banner-acknowledge
# Conflicts:
#	desktop/frontend/src/App.tsx
2026-07-07 19:44:03 +08:00
SivanCola b07876d2c2 fix(frontend): compact tool error details 2026-07-07 19:13:21 +08:00
SivanCola 4b764dae33 Merge pull request #6150 from SivanCola/fix/crlf-edit-diagnostics
Fix CRLF edit diagnostics / 修复 CRLF 编辑诊断
2026-07-07 18:59:26 +08:00
SivanCola 741f8e58d5 Merge pull request #6151 from SivanCola/fix/rebuilt-event-sink-ordering
fix(desktop): order runtime:rebuilt ahead of the rebuilt controller's events
2026-07-07 18:18:26 +08:00
SivanCola 54f11756af fix(desktop): order runtime:rebuilt ahead of the rebuilt controller's events
Review catch on #6146: runtime:rebuilt rode the App-level async event
queue while agent events (approval_request, ask_request) ride each tab
sink's own queue. Two independent queues drain on separate goroutines,
so nothing ordered the dedupe-key reset before the rebuilt controller's
first prompt — a prompt racing in ahead of the reset would still be
muted by the stale key.

Route the notice through the tab sink's queue instead: the rebuild
paths hand the SAME sink to the new controller (snap.sink for
model/effort/token switches, the freshly-swapped tab.sink for
clear-while-running), so FIFO on that one queue guarantees the reset
reaches the frontend before any subsequent agent event from the rebuilt
controller. The App-level queue remains only as a fallback when the
sink cannot deliver (nil sink or cleared webview context) — unordered,
but an unordered notice still beats none.

The Go test now captures through the sink's emitter and fails if any
notice leaks to the App-level queue; reverse-verified against the old
routing (all three switches flagged VIA-APP-QUEUE).
2026-07-07 18:11:35 +08:00
SivanCola c0d0235274 fix(edit): clarify CRLF edit diagnostics 2026-07-07 18:06:07 +08:00
SivanCola eb8f4b82ff Merge pull request #6146 from SivanCola/fix/chime-dedupe-runtime-rebuild
fix(desktop): re-arm the prompt chime after runtime rebuilds
2026-07-07 18:01:13 +08:00
SivanCola 7c437b2d9a fix(desktop): announce runtime:rebuilt so rebuilds re-arm the prompt chime
Review blocker on #6146: the onReady clearing never fires for the very
rebuilds the PR names as the reproduction. SetModelForTab,
SetEffortForTab, and SetTokenModeForTab swap the tab's controller and
return without emitting agent:ready, so "approve once -> switch model
-> next approval stays silent" was still broken. clearActiveSessionRuntime
(clear-while-running) has the same silent swap.

Emitting agent:ready from those paths would drag its reload semantics
along (useController re-hydrates on ready). Add a dedicated lightweight
runtime:rebuilt event instead, emitted with the tab id from all four
swap paths via notifyTabRuntimeRebuilt; the frontend subscribes
alongside onReady and clears that tab's chime keys.

Test-infra fallout: the new emissions ride asyncRuntimeEmitter, whose
nil-hook fallback called Wails runtime.EventsEmit directly — which
log.Fatals on the plain Background contexts desktop tests use, killing
the whole test binary from any emitting path. Route the fallback
through runtimeEventsEmitFallback and neutralize it once in TestMain;
per-instance runtimeEvents.emit hooks keep precedence, so event-
asserting tests are unaffected, and future tests cannot step on this
mine.

TestRuntimeRebuildsEmitRuntimeRebuiltForTab drives real model, effort,
and token-mode switches end-to-end and asserts each emits
runtime:rebuilt carrying the tab id; verified it fails when the
effort-path emit is removed.
2026-07-07 17:45:24 +08:00
SivanCola 17074c1ede Quiet recovery-copy notifications
Recovery copies are a data-protection outcome the backend already handles;
surfacing their lifecycle in the desktop UI has mostly produced "the app
is broken" reports (#6083 discussion, #6127) long after the underlying
false conflicts were fixed. Remove every recovery-lifecycle prompt from
the desktop:

- the "conflict copy" banner (keyed off the permanent
  BranchMeta.Recovered flag, it could never go away),
- the recovery-created toast and the recovery-failed toasts,
- the transcript notices for the whole recovery family (saved-copy,
  kept-current, adopted, adopted-covered), both live and when hydrating
  history — old transcripts stop replaying warnings after upgrade.

What remains: History and ProjectTree still badge copies for
identification; a recovery-lease failure still surfaces through the
ctrl.Snapshot() error path as an autosave-failure notice (that family
is not suppressed); the CLI keeps printing its warnings; the backend
fork/adopt/force-save protection is untouched.
2026-07-07 17:22:17 +08:00
SivanCola 089a3144fb fix(desktop): re-arm the prompt chime after runtime rebuilds
Review catch on #6142: approval/ask ids are per-controller counters
starting at "1" (approvalManager.nextID), and desktop model/effort/
settings switches rebuild the tab's controller — so the first prompt
after a rebuild reuses an id an earlier prompt on the same tab already
consumed, and the chime dedupe misread it as a replay and stayed
silent.

Clear the dedupe keys on agent:ready: a (re)build completion is exactly
the moment ids restart. A tab-scoped ready clears that tab's keys; the
tab-less ready that settings rebuilds emit clears everything —
over-clearing only re-chimes a replayed pending prompt (a desirable
reminder), while under-clearing mutes a live one.
2026-07-07 17:01:53 +08:00
SivanCola b2dae608ad Merge pull request #6142 from SivanCola/fix/background-prompt-attention-sound
fix(desktop): play attention sound for background prompts
2026-07-07 16:55:49 +08:00
SivanCola 65cded04a9 fix(desktop): drop AskCard mount chime, bound the dedupe set
Review follow-ups on the background prompt attention sound:

- AskCard still played the attention chime on mount, so every ask
  prompt on the active tab chimed TWICE after the global event-stream
  path landed (the PR removed the ApprovalModal side effect but missed
  AskCard). The chime now plays exclusively from the runtime event
  stream, keyed by prompt id.
- The "test" script was missing sound.test.ts (only test:all had it),
  so `pnpm test` skipped the new suite.
- The chime dedupe set grew without bound (one entry per prompt id for
  the app's lifetime). It now self-prunes past 512 entries, dropping
  the oldest half — replay dedupe only needs recently seen ids. Pinned
  by a 2000-prompt bounded-growth test that also asserts recent ids
  stay deduped after pruning.
2026-07-07 16:49:07 +08:00
SivanCola 2fbce2834a Merge pull request #6141 from SivanCola/fix/hook-node-eval-autorepair
Fix quoted node eval hook autorepair / 修复 node -e hook 引号自愈
2026-07-07 16:35:34 +08:00
SivanCola 9535e411e3 hook: document and pin the direct-exec boundary, harden quote trimming
Review follow-ups on the node -e autorepair:

- spawnCommand now direct-execs only commands this call actually
  repaired, plus (on Windows) recognized node stdin-hook evals where
  cmd /c mangles quoted JS. Well-formed POSIX commands keep sh -c
  verbatim. Behaviorally this matches the previous code — StaticFields
  already rejects runtime expansions, so only expansion-free commands
  ever hit direct exec, where the two vehicles are equivalent — but the
  boundary is now explicit, commented, and pinned by
  TestWellFormedNodeEvalKeepsShellSemantics ($VAR expansion must
  survive for a well-formed stdin-hook command).
- The quote-trim helpers now require the closing marker instead of
  prefix-only matching, so an unterminated candidate ("\"abc with no
  tail) cannot be "repaired" into an unintended script.
- Drop the unexplained runtime.Gosched() from the test mock provider:
  hook/cli/agent/control suites plus 5x statusline stress pass without
  it.
2026-07-07 16:29:26 +08:00
SivanCola 7eeb5c2125 Merge pull request #6140 from SivanCola/feature/claude-plugin-manifest-compat
Support Claude plugin manifest installs / 支持 Claude 插件清单安装
2026-07-07 16:12:34 +08:00
SivanCola 725e10e560 fix(hook): repair quoted node eval hooks 2026-07-07 16:02:39 +08:00
SivanCola c0100dd509 fix: scope prompt chime dedupe by tab 2026-07-07 16:02:08 +08:00
SivanCola 6a68799e18 fix(pluginpkg): discover conventional Claude skill dirs and warn on unmapped capabilities
Review findings on the Claude manifest support:

- A standard Claude plugin ships a metadata-only plugin.json and relies
  on Claude's auto-discovery of the conventional skills/ directory (packs
  in the wild also use .claude/skills/). Reading only the explicit
  `skills` field installed such packages as zero capabilities with no
  warning — "install succeeded" while nothing usable landed. Claude-kind
  parsing now falls back to the conventional directories when the
  manifest declares none, adopting a directory only if it actually holds
  a <name>/SKILL.md. Explicit declarations disable the fallback; codex
  parsing is untouched.
- Conventional Claude capabilities Reasonix does not map yet (commands/,
  agents/, hooks/hooks.json, .mcp.json) now surface as install warnings
  instead of being silently dropped.
- Negative test pins the includeCodexSessionStartHook boundary: a
  claude-kind package shipping hooks/session-start-codex must not get it
  registered as an executable SessionStart hook.
- Docs: retitle the compatibility section to "Codex & Claude", document
  the fallback, the warnings, and that marketplace.json indexes are out
  of scope.

Fallback reverse-verified: removing the convention-dir call fails
TestParseClaudePluginConventionSkillDirs.
2026-07-07 15:58:05 +08:00
SivanCola 60735b4f02 fix: play attention sound for background prompts 2026-07-07 15:47:34 +08:00
SivanCola c070dc7432 Support Claude plugin manifests 2026-07-07 15:25:59 +08:00
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
199 changed files with 12219 additions and 1003 deletions

No files matched your search

+26
View File
@@ -1,2 +1,28 @@
.gitattributes text eol=lf
.gitignore text eol=lf
.npmrc text eol=lf
*.astro text eol=lf
*.css text eol=lf
*.go text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mjs text eol=lf
*.mod text eol=lf
*.nsi text eol=lf
*.plist text eol=lf
*.py text eol=lf
*.rs text eol=lf
*.sh text eol=lf
*.sql text eol=lf
*.sum text eol=lf
*.toml text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.txt text eol=lf
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
+47
View File
@@ -163,6 +163,53 @@ jobs:
- name: test
run: go test ./...
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
# run on the ubuntu leg above.
desktop-windows:
runs-on: windows-latest
defaults:
run:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/[email protected]
# go:embed of frontend/dist needs a built frontend before the package
# compiles, same as the ubuntu desktop leg.
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: test (Windows-only path semantics)
timeout-minutes: 10
run: go test . -run 'OnWindows$' -v
lint:
runs-on: ubuntu-latest
steps:
+49
View File
@@ -122,3 +122,52 @@ jobs:
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
+62 -14
View File
@@ -523,6 +523,7 @@ func (a *App) tabsRestoredSignal() <-chan struct{} {
func (a *App) showMainWindow() {
if a.ctx != nil {
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
a.kickDeferredRebuildRetry()
}
}
@@ -639,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
@@ -983,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
@@ -1185,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}
@@ -1513,6 +1532,10 @@ func (a *App) NewSession() error {
// 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()
@@ -1619,11 +1642,27 @@ func (a *App) ClearSession() error {
return userFacingSessionLeaseError("", err)
}
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")
@@ -1711,7 +1750,9 @@ 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()
@@ -1738,7 +1779,8 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
tab.SessionPath = path
tab.Label = newCtrl.Label()
tab.Ready = true
tab.StartupErr = ""
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.
@@ -1750,6 +1792,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
tab.resetTelemetry(path)
oldCtrl.CloseAfterDestroy()
a.emitProjectTreeChanged()
a.notifyTabRuntimeRebuilt(tab)
return nil
}
@@ -2230,7 +2273,7 @@ func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool,
WorkspaceRoot: s.WorkspaceRoot,
TopicID: s.TopicID,
TopicTitle: s.TopicTitle,
Recovered: s.Recovered,
Recovered: sessionInfoIsAutomaticRecovery(s),
}
}
@@ -2731,7 +2774,7 @@ func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
return fmt.Errorf("workspaceRoot is required")
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
a.registerProjectRoot(workspaceRoot)
actualRoot = workspaceRoot
} else {
actualRoot = globalWorkspaceRoot()
@@ -3113,7 +3156,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
clearTabStartupError(tab)
tab.ActivityStatus = ""
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
a.saveTabsLocked()
@@ -3155,7 +3198,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
clearTabStartupError(tab)
tab.ActivityStatus = ""
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
a.saveTabsLocked()
@@ -3773,7 +3816,7 @@ func (a *App) ListWorkspaces() []WorkspaceMeta {
out = append(out, WorkspaceMeta{
Path: project.Root,
Name: projectDisplayName(project),
Current: activeRoot != "" && project.Root == activeRoot,
Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
})
}
return out
@@ -4019,6 +4062,7 @@ func (a *App) singleSurfaceLayoutEnabled() bool {
type HistoryMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Detail string `json:"detail,omitempty"`
SubmitText string `json:"submitText,omitempty"`
CheckpointTurn *int `json:"checkpointTurn,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
@@ -4793,6 +4837,7 @@ type previewEventRecord struct {
UpdatedAt json.RawMessage `json:"updatedAt"`
UpdatedAtSnake json.RawMessage `json:"updated_at"`
Text string `json:"text"`
Detail string `json:"detail"`
Content string `json:"content"`
Reasoning string `json:"reasoning"`
ReasoningContent string `json:"reasoningContent"`
@@ -4900,7 +4945,7 @@ func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
if level != "warn" {
level = "info"
}
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail})
case "compaction_started":
c := rec.compactionPayload()
out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
@@ -7303,6 +7348,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
// The runtime now reflects the on-disk config; drop any deferred refresh.
a.clearDeferredRebuild(tab.ID)
a.persistTabSessionPath(tab, path)
a.notifyTabRuntimeRebuilt(tab)
return nil
}
@@ -7443,7 +7489,7 @@ 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()
@@ -7454,6 +7500,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
a.clearDeferredRebuild(tab.ID)
a.persistTabSessionPath(tab, path)
a.notifyTabRuntimeRebuilt(tab)
return nil
}
@@ -7570,7 +7617,7 @@ 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()
@@ -7581,6 +7628,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
a.clearDeferredRebuild(tab.ID)
a.persistTabSessionPath(tab, path)
a.notifyTabRuntimeRebuilt(tab)
return nil
}
+195 -4
View File
@@ -31,6 +31,7 @@ import (
"reasonix/internal/pluginpkg"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -114,16 +115,26 @@ func isolateDesktopUserDirs(t *testing.T) string {
func primarySessionFiles(paths []string) []string {
out := make([]string, 0, len(paths))
for _, path := range paths {
base := filepath.Base(path)
if strings.HasSuffix(base, ".jsonl") &&
!strings.HasSuffix(base, ".events.jsonl") &&
!strings.HasSuffix(base, ".guardian.jsonl") {
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 {
@@ -1376,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")
@@ -2079,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
@@ -2189,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",
@@ -2344,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 {
+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-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
},
"dependencies": {
"@gsap/react": "^2.1.2",
+129 -50
View File
@@ -38,14 +38,14 @@ import { useToast } from "./lib/toast";
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
import { asArray } from "./lib/array";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
import { useController, type Item, type LiveStream } from "./lib/useController";
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
import { localizedBackendNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered } from "./lib/bridge";
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
import { playSuccessChime } from "./lib/sound";
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
import { Transcript } from "./components/Transcript";
import { Composer } from "./components/Composer";
import { TodoPanel } from "./components/TodoPanel";
import { ApprovalModal } from "./components/ApprovalModal";
import { ApprovalModal, approvalToolLabel } from "./components/ApprovalModal";
import { AskCard } from "./components/AskCard";
import { UndoRewindBanner } from "./components/UndoRewindBanner";
import { ClearContextCard } from "./components/ClearContextCard";
@@ -161,6 +161,83 @@ import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
import logoWordmark from "./assets/logo-wordmark.svg";
function noticePreviewMockEnabled(): boolean {
const value = browserMockScenarioParam();
return value === "notice" || value === "notices" || value === "notice-preview";
}
function noticePreviewItems(): Item[] {
const notice = (index: number, level: "info" | "warn", text: string, detail: string): Item => ({
kind: "notice",
id: `notice-preview-${index}`,
level,
text: localizedBackendNoticeText(text),
detail,
});
return [
notice(0, "info", "Task status needs one more check; asking the assistant to finish or explain what is blocking it.", "final-answer readiness blocked: latest successful todo_write still has incomplete items: UI spinner in_progress, settings persistence pending"),
notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying"),
notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute"),
notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing"),
notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts"),
notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"),
notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"),
notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"),
notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"),
notice(9, "info", "Planning mode enabled for this multi-step task.", "auto plan detector matched implementation task with repository edits and verification steps"),
notice(10, "info", "Plan detection requested a plan.", "plan detector confidence=0.83; task includes multiple dependent UI/backend changes"),
notice(11, "info", "Plan detection was uncertain; using the fallback planner heuristic.", "plan detector confidence=0.48; fallback heuristic selected because files and tests are involved"),
notice(12, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"),
notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"),
notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"),
notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"),
notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"),
notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"),
notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"),
notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""),
notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""),
notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""),
notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"),
notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"),
notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"),
notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"),
notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"),
];
}
function NoticePreviewPanel({ detailsLabel }: { detailsLabel: string }) {
return (
<div
style={{
flex: "1 1 auto",
minHeight: 0,
overflow: "auto",
padding: "44px 24px 128px",
}}
>
<div style={{ maxWidth: 920, margin: "0 auto" }}>
{noticePreviewItems().map((item) => {
if (item.kind !== "notice") return null;
return (
<div key={item.id} className={`notice-line notice-line--${item.level}`} data-entrance="true">
<span className="notice-line__icon">{item.level === "warn" ? "⚠ " : "ℹ "}</span>
<div className="notice-line__text">
{item.text}
{item.detail ? (
<details className="notice-line__details">
<summary>{detailsLabel}</summary>
<div>{item.detail}</div>
</details>
) : null}
</div>
</div>
);
})}
</div>
</div>
);
}
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
@@ -854,6 +931,9 @@ function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream)
break;
case "notice":
lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
if (item.detail?.trim()) {
lines.push("Details:", "", item.detail.trim(), "");
}
break;
case "compaction":
lines.push("### Context Compaction", "");
@@ -1010,6 +1090,7 @@ export default function App() {
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
const attentionChimeEvents = useRef(new Set<string>());
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
// workspace changes, git history, and session metadata after AI tool writes.
useEffect(() => {
@@ -1017,11 +1098,31 @@ export default function App() {
if (e.kind === "turn_done") {
setDockRefreshKey((v) => v + 1);
}
if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) {
playAttentionChime();
}
if (e.kind === "turn_done") {
if (!e.err) playSuccessChime();
}
});
return unsub;
// Runtime rebuilds (model/effort/settings switch) replace the controller,
// whose approval/ask ids restart from "1" — stale dedupe keys would mute
// the first prompt after a rebuild. agent:ready fires when a (re)build
// completes; clear that tab's keys (or all, for tab-less ready events).
const unsubReady = onReady((readyTabId) => {
clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
});
// Model/effort/token-mode switches and clear-while-running replace the
// controller WITHOUT an agent:ready — they signal runtime:rebuilt instead
// (a ready here would trigger a full session reload the UI already did).
const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => {
clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
});
return () => {
unsub();
unsubReady();
unsubRebuilt();
};
}, []);
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
@@ -2266,7 +2367,6 @@ export default function App() {
if (!rewindState) return transcriptItems;
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
}, [transcriptItems, rewindState]);
const latestGuidanceConsumed = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const item = state.items[i];
@@ -2593,36 +2693,10 @@ export default function App() {
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
[enqueueNavigation]);
useEffect(() => {
return onSessionRecovered((event) => {
const scope = event.scope === "project" ? "project" : "global";
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
setProjectRevision((value) => value + 1);
void refreshTabMetas();
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
? {
actionLabel: t("recovery.open"),
durationMs: 9000,
onAction: () => {
void enqueueNavigation({
kind: "topic",
scope,
workspaceRoot,
topicId: event.topicId || "",
sessionPath: event.recoveryPath || "",
});
},
}
: { durationMs: 9000 });
});
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
useEffect(() => {
return onSessionRecoveryFailed((event) => {
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
showToast(t(key), "error", { durationMs: 9000 });
});
}, [showToast, t]);
useEffect(() => onSessionRecovered(() => {
setProjectRevision((value) => value + 1);
void refreshTabMetas();
}), [refreshTabMetas]);
const handleNewTab = useCallback(async () => {
closeTransientOverlays();
@@ -2747,6 +2821,19 @@ export default function App() {
},
[state.running, deleteSession, refreshHistoryView],
);
const onDeleteManySessions = useCallback(
async (paths: string[]) => {
if (state.running) return;
const uniquePaths = Array.from(new Set(paths));
for (const path of uniquePaths) {
// Best effort per path: one locked/missing file must not abandon the
// rest of the sweep. The final refresh reconciles whatever happened.
await deleteSession(path).catch(() => undefined);
}
await refreshHistoryView();
},
[state.running, deleteSession, refreshHistoryView],
);
const onRenameSession = useCallback(
async (path: string, title: string) => {
if (state.running) return;
@@ -2884,13 +2971,6 @@ export default function App() {
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const recoveryBannerTitle = activeTab?.recovered
? [
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
].filter(Boolean).join(" · ")
: "";
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const windowsFramelessChrome = desktopPlatform === "windows";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
@@ -3442,13 +3522,6 @@ export default function App() {
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
)}
{activeTab?.recovered && !sidebarImDetailConnection && (
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
<span className="banner__badge">{t("recovery.branch")}</span>
<span>{t("recovery.banner")}</span>
</div>
)}
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
<main className="main">
@@ -3460,6 +3533,8 @@ export default function App() {
onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)}
onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)}
/>
) : noticePreviewMockEnabled() ? (
<NoticePreviewPanel detailsLabel={t("notice.details")} />
) : (
<Transcript
items={displayItems}
@@ -3535,6 +3610,7 @@ export default function App() {
onStop={() => {
cancel();
}}
toolApprovalMode={toolApprovalMode}
/>
)}
{state.ask && (
@@ -3586,6 +3662,8 @@ export default function App() {
turnStartAt={state.turnStartAt}
turnTokens={state.turnTokens}
retry={state.retry}
pendingApprovalLabel={state.approval ? approvalToolLabel(state.approval.tool, t) : null}
pendingAsk={state.ask != null}
transientDismissSignal={transientOverlayDismissSignal}
sessionKey={composerSessionKey}
fileRefRefreshKey={composerFileRefRefreshKey}
@@ -3731,6 +3809,7 @@ export default function App() {
onRestore={onRestoreTrashedSession}
onPurge={onPurgeTrashedSession}
onPurgeAll={onPurgeAllTrashedSessions}
onDeleteMany={onDeleteManySessions}
onClose={closeHistory}
/>
</Suspense>
@@ -312,6 +312,25 @@ console.log("\napproval modal file references");
);
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
// Consequence preview row: follows the keyboard-selected action by default,
// arrow navigation, and hover; each pill carries a native title fallback.
const consequence = () => document.querySelector(".approval-consequence__text")?.textContent ?? null;
eq(consequence(), "Allow this call only; the next one asks again.", "consequence row previews the selected action by default");
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
await flushTimers();
});
eq(consequence(), "Allow matching calls until this session ends; resets on restart.", "arrow navigation moves the consequence preview");
const pills = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
eq(pills[2]?.getAttribute("title"), "Save a persistent allow rule; future sessions stop asking too.", "action pills carry a native title fallback");
await act(async () => {
pills[3].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
await flushTimers();
});
eq(consequence(), "Reject this call; the model sees the refusal and continues.", "hover overrides the consequence preview");
await act(async () => {
root.unmount();
});
@@ -111,6 +111,29 @@ console.log("\nask card layout");
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
// Detail preview row: shows the first described option by default, follows
// hover, and each option carries a native-title fallback for truncation.
const detail = document.querySelector(".ask-shelf__detail") as HTMLElement | null;
if (!detail) throw new Error("ask detail preview row did not render");
eq(
detail.querySelector(".ask-shelf__detail-text")?.textContent,
ask.questions[0].options[0].description,
"detail row previews the first described option by default",
);
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
eq(optionButtons[1]?.getAttribute("title"), ask.questions[0].options[1].description, "options carry a native title fallback");
await act(async () => {
optionButtons[1].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
await flushTimers();
});
eq(
document.querySelector(".ask-shelf__detail-text")?.textContent,
ask.questions[0].options[1].description,
"detail row follows the hovered option",
);
await act(async () => {
root.unmount();
});
@@ -0,0 +1,3 @@
// Stand-in for bundler-handled asset imports (.svg) in tsx-run tests; see the
// resolve hook in history-recovery-copies.test.tsx.
export default "";
@@ -0,0 +1,85 @@
// Run: tsx src/__tests__/bridge-breadcrumbs.test.ts
import { snapshotBreadcrumbs } from "../lib/breadcrumbs";
import { app } from "../lib/bridge";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function ensureWindow() {
if (typeof window === "undefined") {
(globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
}
}
function newBreadcrumbMessages(since: number, cat: string): string[] {
return snapshotBreadcrumbs()
.slice(since)
.filter((crumb) => crumb.cat === cat)
.map((crumb) => crumb.msg);
}
console.log("\nbridge breadcrumbs");
const previousPerformance = (globalThis as { performance?: Performance }).performance;
let now = 0;
(globalThis as Record<string, unknown>).performance = { now: () => now };
ensureWindow();
const previousGo = window.go;
window.go = {
main: {
App: {
async IsMainWindowMaximised() {
now = 42;
return true;
},
async CheckUpdate() {
now = 125;
throw new Error("offline");
},
} as never,
},
};
const successStart = snapshotBreadcrumbs().length;
now = 10;
await app.IsMainWindowMaximised();
const successBridge = newBreadcrumbMessages(successStart, "bridge");
ok(
successBridge.includes("window IsMainWindowMaximised") &&
successBridge.includes("window IsMainWindowMaximised done ms=32"),
"records bridge start and completion timing breadcrumbs",
);
const errorStart = snapshotBreadcrumbs().length;
now = 100;
try {
await app.CheckUpdate();
} catch {
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
}
const errorBridge = newBreadcrumbMessages(errorStart, "bridge");
const bridgeErrors = newBreadcrumbMessages(errorStart, "bridge.error");
ok(errorBridge.includes("update CheckUpdate"), "records bridge start breadcrumb before failed calls");
ok(bridgeErrors.includes("CheckUpdate ms=25"), "records bridge failure timing breadcrumbs");
window.go = previousGo;
if (previousPerformance) {
(globalThis as Record<string, unknown>).performance = previousPerformance;
} else {
delete (globalThis as { performance?: Performance }).performance;
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -257,7 +257,7 @@ console.log("\ncomposer goal toggle");
turnStartAt: Date.now(),
});
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stopButton) throw new Error("composer stop button did not render");
await act(async () => {
@@ -643,7 +643,7 @@ console.log("\ncomposer goal toggle");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
eq(textarea.placeholder, "Running — type guidance, Enter adds it to the queue", "running composer explains queued guidance input");
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
ok(sendButton.disabled === true, "running steer button stays disabled without input");
@@ -0,0 +1,327 @@
// Run: tsx src/__tests__/composer-run-strip.test.tsx
//
// The run state lives inside the composer card (no floating pill, no layout
// jump), stop has a fixed home next to send, and a pending approval/ask shifts
// the strip into a waiting state instead of a ticking "working" spinner.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.InputEvent = dom.window.InputEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.ResizeObserver = TestResizeObserver;
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
return dom;
}
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const calls = { cancel: 0 };
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal" as CollaborationMode,
toolApprovalMode: "ask" as ToolApprovalMode,
tokenMode: "full" as TokenMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
onSend: () => {},
onCancel: () => {
calls.cancel += 1;
return undefined;
},
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: () => {},
onSetToolApprovalMode: () => {},
onToggleYoloApprovalMode: () => {},
onClearGoal: () => {},
onSwitchModel: () => {},
onSetEffort: () => {},
onSetTokenMode: () => {},
ready: true,
...props,
};
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
</ToastProvider>
</LocaleProvider>,
);
await flushTimers();
});
};
await paint();
return { root, calls, rerender: paint };
}
console.log("\ncomposer run strip");
// Idle: no strip, no stop button, plain send arrow.
{
const dom = installDom();
const { root } = await renderComposer();
eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip");
eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
{
const dom = installDom();
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
const strip = document.querySelector(".composer-card .composer-run-strip");
ok(strip !== null, "running strip renders inside the composer card");
const ticker = strip?.querySelector(".composer-run-strip__text");
eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
const live = strip?.querySelector(".sr-only[role=\"status\"]");
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stop) throw new Error("running composer stop button did not render");
await act(async () => {
stop.click();
await flushTimers();
});
eq(calls.cancel, 1, "stop button next to send cancels the turn");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Waiting on approval: strip switches to the waiting state and stops ticking.
{
const dom = installDom();
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
const strip = document.querySelector(".composer-run-strip");
ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting");
const text = strip?.querySelector(".composer-run-strip__text");
eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval");
eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
await rerender({ pendingApprovalLabel: null, pendingAsk: true });
eq(
document.querySelector(".composer-run-strip__text")?.textContent,
"Waiting for your answer",
"pending ask question shows the ask waiting state",
);
ok(
modeButtons.every((b) => b.disabled),
"approval bar stays disabled for non-approval reasons",
);
await rerender({ pendingAsk: false, disabled: false });
ok(
document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
"resolving the prompt returns the strip to the ticking spinner",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Cancel restores queued guidance: stop means "stop acting", never "discard
// what I typed".
{
const dom = installDom();
const { root, calls } = await renderComposer({
running: true,
turnStartAt: Date.now(),
guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"],
});
ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf");
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stop) throw new Error("stop button did not render");
await act(async () => {
stop.click();
await flushTimers();
});
eq(calls.cancel, 1, "stop cancels the running turn");
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft");
eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Waiting on the user pauses the ticker clock: elapsed time means model time.
{
const dom = installDom();
const start = Date.now() - 30000;
const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2400));
});
await rerender({ pendingApprovalLabel: null, disabled: false });
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Resize consistency: --composer-height always carries the logical height in
// every writer (React render, live drag, keyboard), with the run strip's
// reservation isolated in a CSS calc — so dragging a resized composer during a
// running turn cannot flash-shrink the card.
{
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
ok(
stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"),
"resized card height combines logical height and strip reservation in CSS",
);
const dom = installDom();
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
await act(async () => {
handle.focus();
handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
await flushTimers();
});
const card = document.querySelector(".composer-card") as HTMLElement;
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
// Drag while running: the live writer stays in logical-height space.
await act(async () => {
handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 }));
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card");
await act(async () => {
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 }));
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 }));
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
await rerender({ running: false, turnStartAt: undefined });
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -5,6 +5,7 @@ import {
buildPerformancePayload,
formatPerformanceContext,
globalCrashReportReason,
installPerformancePressureMonitor,
normalizeCrashError,
parseReportedPerf,
performanceLabelForReason,
@@ -151,6 +152,42 @@ eq(shouldPromptForPerformanceLabel(false, 5 * 60_000, false), false, "respects t
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, true), false, "never prompts while the window is hidden");
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false, false), false, "never prompts while unfocused");
{
let interval: (() => void) | undefined;
let now = 0;
let promptPainted = false;
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const previousPerformance = (globalThis as any).performance;
const previousPerformanceObserver = (globalThis as any).PerformanceObserver;
(globalThis as any).performance = { now: () => now };
(globalThis as any).window = {
runtime: {},
setInterval: (cb: () => void) => {
interval = cb;
return 1;
},
};
(globalThis as any).document = {
visibilityState: "visible",
hasFocus: () => true,
addEventListener: () => {},
getElementById: () => {
promptPainted = true;
return null;
},
};
(globalThis as any).PerformanceObserver = undefined;
installPerformancePressureMonitor();
now = 26_000;
interval?.();
eq(promptPainted, false, "first post-grace event-loop tick primes without reporting startup backlog");
(globalThis as any).window = previousWindow;
(globalThis as any).document = previousDocument;
(globalThis as any).performance = previousPerformance;
(globalThis as any).PerformanceObserver = previousPerformanceObserver;
}
const reportedPerf = serializeReportedPerf(new Set(["performance.lag"]), "abc123");
eq([...parseReportedPerf(reportedPerf, "abc123")], ["performance.lag"], "round-trips reported labels for the same build");
eq([...parseReportedPerf(reportedPerf, "def456")], [], "re-surfaces reported labels on a new build");
@@ -0,0 +1,216 @@
// Run: tsx src/__tests__/history-recovery-copies.test.tsx
//
// Recovery-copy bulk actions in HistoryPanel: the history view sweeps idle
// copies into the trash (skipping current/open ones), the trash view purges
// them, and both flows keep normal sessions untouched.
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { SessionMeta } from "../lib/types";
// HistoryPanel transitively imports Welcome's SVG wordmark; tsx has no asset
// loader, so redirect .svg specifiers to an empty-string module stub, the way
// Vite would default-export a URL.
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".svg")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (JSON.stringify(actual) === JSON.stringify(expected)) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
return dom;
}
const now = 1_750_000_000_000;
function session(overrides: Partial<SessionMeta> & { path: string }): SessionMeta {
return {
preview: "session preview",
turns: 3,
createdAt: now - 3_600_000,
lastActivityAt: now,
modTime: now,
current: false,
open: false,
...overrides,
};
}
async function renderPanel(props: Record<string, unknown>) {
const { HistoryPanel } = await import("../components/HistoryPanel");
const { LocaleProvider } = await import("../lib/i18n");
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<HistoryPanel
running={false}
onResume={() => {}}
onPreview={async () => []}
onDelete={() => {}}
onRename={() => {}}
onClose={() => {}}
{...props}
/>
</LocaleProvider>,
);
await flushTimers(30);
});
return root;
}
function findButton(text: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === text) as
| HTMLButtonElement
| undefined;
}
async function click(button: HTMLButtonElement) {
await act(async () => {
button.click();
await flushTimers(20);
});
}
console.log("\nhistory panel recovery-copy bulk actions");
// History view: the sweep button trashes idle recovery copies only.
{
const dom = installDom();
const deleted: string[][] = [];
const purged: string[][] = [];
const root = await renderPanel({
kind: "history",
sessions: [
session({ path: "/s/normal.jsonl" }),
session({ path: "/s/idle-recovery-0123456789abcdef.jsonl", recovered: true }),
session({ path: "/s/open-recovery-0123456789abcdef.jsonl", recovered: true, open: true }),
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
],
onDeleteMany: (paths: string[]) => deleted.push(paths),
onPurgeAll: (paths: string[]) => purged.push(paths),
});
const sweep = findButton("Trash recovery copies");
ok(Boolean(sweep), "history view shows the trash-recovery-copies button");
if (sweep) {
await click(sweep); // arm
const confirm = findButton("Confirm trash copies");
ok(Boolean(confirm), "sweep arms into a confirm step");
if (confirm) await click(confirm);
}
eq(deleted, [["/s/idle-recovery-0123456789abcdef.jsonl"]], "sweep trashes only idle recovery copies");
eq(purged, [], "history sweep never purges");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// History view: no idle copies → no sweep button.
{
const dom = installDom();
const root = await renderPanel({
kind: "history",
sessions: [
session({ path: "/s/normal.jsonl" }),
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
],
onDeleteMany: () => {},
});
ok(!findButton("Trash recovery copies"), "history view hides the sweep button when every copy is live");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Trash view: clear copies purges recovery copies, empty trash stays separate.
{
const dom = installDom();
const purged: string[][] = [];
const root = await renderPanel({
kind: "trash",
sessions: [
session({ path: "/t/normal.jsonl", deletedAt: now }),
session({ path: "/t/a-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
session({ path: "/t/b-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
],
onRestore: () => {},
onPurge: () => {},
onPurgeAll: (paths: string[]) => purged.push(paths),
});
const clear = findButton("Clear copies");
ok(Boolean(clear), "trash view shows the clear-copies button");
if (clear) {
await click(clear); // arm
const confirm = findButton("Confirm clear copies");
ok(Boolean(confirm), "clear copies arms into a confirm step");
if (confirm) await click(confirm);
}
eq(
purged,
[["/t/a-recovery-0123456789abcdef.jsonl", "/t/b-recovery-0123456789abcdef.jsonl"]],
"clear copies purges every trashed recovery copy and nothing else",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,210 @@
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(ready: boolean): TabMeta {
return {
id: "tab-ready",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: "topic-ready",
topicTitle: "Ready race",
sessionPath: "/repo/sessions/ready.jsonl",
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo",
};
}
function meta(ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready event meta sync");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
window.runtime = {
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [tabMeta(backendReady)];
},
MetaForTab: async () => {
metaCalls += 1;
return meta(backendReady);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async () => {
historyCalls += 1;
const messages = await historyGate.promise;
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one history hydration");
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
backendReady = true;
await act(async () => {
for (const handler of readyHandlers) handler("tab-ready");
await flushPromises();
});
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,220 @@
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 100; attempt += 1) {
await act(async () => {
await flushPromises(25);
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
return {
id,
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: `topic-${id}`,
topicTitle: id,
sessionPath: `/repo/sessions/${id}.jsonl`,
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active,
cwd: "/repo",
};
}
function meta(tabId: string, ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
sessionPath: `/repo/sessions/${tabId}.jsonl`,
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready meta reconcile");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
let approvalModeCalls = 0;
const metaTabIds: string[] = [];
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [
tabMeta("tab-ready", backendReady, true),
tabMeta("tab-inactive", true, false),
];
},
MetaForTab: async (tabId: string) => {
metaCalls += 1;
metaTabIds.push(tabId);
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async (tabId: string) => {
historyCalls += 1;
const messages = await historyGate.promise;
return {
messages,
startTurn: 0,
endTurn: messages.filter((message) => message.role === "user").length,
totalTurns: messages.filter((message) => message.role === "user").length,
hasOlder: false,
};
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SetToolApprovalModeForTab: async () => {
approvalModeCalls += 1;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one active-tab history hydration");
eq(listTabsCalls, 1, "startup fetches the active tab once");
backendReady = true;
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
eq(historyCalls, 1, "ready polling does not start another history hydration");
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,31 @@
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
console.log("\nquiet recovery prompt privacy");
ok(!appSource.includes("banner--recovery"), "App does not render a persistent recovery banner");
ok(!appSource.includes("recoveryDigest"), "App does not expose recovery digest through recovery prompts");
ok(!appSource.includes("recoveryParentId"), "App does not expose parent session id through recovery prompts");
ok(!appSource.includes("recoveryBannerTitle"), "App does not build a detailed recovery tooltip");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,62 @@
// Run: tsx src/__tests__/recovery-quiet-notifications.test.ts
//
// Recovery copies remain protected by the backend, but the desktop UI should
// not interrupt users with recovery lifecycle banners, toasts, or transcript
// notices.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { en } from "../locales/en";
import { zh } from "../locales/zh";
import { zhTW } from "../locales/zh-TW";
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
console.log("\nquiet recovery notifications");
ok(!appSource.includes("banner--recovery"), "App does not render a recovery banner");
ok(!appSource.includes("recovery.toast"), "App does not show a recovery-created toast");
ok(!appSource.includes("onSessionRecoveryFailed"), "App does not show recovery failure toasts");
ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery acknowledgement controls");
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText))"), "raw quiet notices are skipped before localization");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text))"), "localized quiet notices are skipped before rendering");
const removedPromptKeys = [
"recovery.open",
"recovery.toast",
"recovery.failedLease",
"recovery.failedUnavailable",
"recovery.banner",
"recovery.bannerCompare",
"recovery.bannerDismiss",
"recovery.openOriginalFailed",
];
for (const [name, dict] of [["en", en], ["zh", zh], ["zh-TW", zhTW]] as const) {
for (const key of removedPromptKeys) {
ok(!(key in dict), `${name} locale omits ${key}`);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -42,6 +42,19 @@ function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function installCanvasMock(win: Window) {
Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value(type: string) {
if (type !== "2d") return null;
return {
font: "",
measureText: () => ({ width: 0 }),
} as unknown as CanvasRenderingContext2D;
},
});
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
@@ -172,6 +185,7 @@ const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body>
pretendToBeVisual: true,
url: "http://localhost/",
});
installCanvasMock(dom.window as unknown as Window);
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
@@ -0,0 +1,64 @@
// Run: tsx src/__tests__/sound.test.ts
import { attentionChimeEventKey, clearAttentionChimeKeys, shouldPlayAttentionChimeForEvent } from "../lib/sound";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
console.log("\nsound notifications");
{
eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key");
eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key");
eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key");
eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys");
}
{
const seen = new Set<string>();
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays");
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play");
}
{
// The dedupe set stays bounded: after many unique prompts it self-prunes
// while still deduping recently seen ids.
const seen = new Set<string>();
for (let i = 0; i < 2000; i++) {
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen);
}
eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning");
}
{
// Runtime rebuild clears dedupe keys so reissued ids chime again.
const seen = new Set<string>();
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen);
shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen);
clearAttentionChimeKeys(seen, "tab-a");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again");
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear");
clearAttentionChimeKeys(seen);
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key");
}
if (failed) {
console.error(`sound notifications: ${failed} failed, ${passed} passed`);
process.exit(1);
}
console.log(`sound notifications: ${passed} passed`);
@@ -0,0 +1,114 @@
// Run: tsx src/__tests__/tool-card-error-details.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { ToolCard } from "../components/ToolCard";
import { LocaleProvider } from "../lib/i18n";
import type { Item } from "../lib/useController";
type ToolItem = Extract<Item, { kind: "tool" }>;
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
dom.window.matchMedia = () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
return dom;
}
console.log("\ntool card error details");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const error = "evidence 1: verification command \"Select-String -Path @(...long...)\" has no matching successful receipt — cite the command exactly as it ran in the session; commands that ran: [\"unique-command-tail\"] — pick the matching one and retry complete_step";
const item: ToolItem = {
kind: "tool",
id: "complete-step-error",
name: "complete_step",
args: "",
readOnly: true,
status: "error",
output: `error: ${error}`,
error,
durationMs: 62,
};
await act(async () => {
root.render(
React.createElement(LocaleProvider, null,
React.createElement(ToolCard, { item }),
),
);
await flushTimers();
});
eq(document.querySelectorAll(".code-block").length, 0, "duplicate error output is not rendered as a code block");
ok(document.querySelector(".tool__err-summary")?.textContent?.includes("verification command has no matching successful receipt"), "compact error summary is visible");
ok(!document.querySelector(".tool__err-details"), "full error details are hidden by default");
ok(!document.body.textContent?.includes("unique-command-tail"), "long receipt list is not visible before expansion");
const toggle = document.querySelector(".tool__err-toggle") as HTMLButtonElement | null;
if (!toggle) throw new Error("error details toggle did not render");
await act(async () => {
toggle.click();
await flushTimers();
});
ok(document.querySelector(".tool__err-details")?.textContent?.includes("unique-command-tail"), "full error details render after expansion");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } 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"] | "" };
@@ -172,6 +172,111 @@ console.log("\nuse controller meta");
'Model "old/model" is no longer available; switched to new/model.',
"backend model fallback notice is localized",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"legacy recovery branch notice can be normalized without exposing internal branch id",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery copy notice can be normalized",
);
eq(
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"legacy repeated recovery conflict notice can be normalized",
);
eq(
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"repeated recovery conflict notice can be normalized",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
"The session changed on disk, so Reasonix adopted the newer transcript.",
"adopted transcript notice can be normalized",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
"covered adopted transcript notice can be normalized",
);
eq(
localizedBackendNoticeText("The assistant answered before taking action; asking it to use the required tools."),
"The assistant answered before taking action; asking it to use the required tools.",
"canonical backend notice is routed through the locale dictionary",
);
eq(
localizedBackendNoticeText("background export failed: needs attention"),
"Background export needs attention.",
"dynamic background job notice is user-facing",
);
}
{
let s = reducer(initialState, {
type: "event",
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
});
s = reducer(s, {
type: "event",
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
});
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent in the live transcript");
eq(s.seq, 0, "silent recovery notices do not consume sequence ids");
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
}
{
const quietLifecycleMessages = [
{ level: "info", text: "guardian enabled · model=guardian-test" },
{ level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
{ level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
{ level: "info", text: "settings applied: session refreshed after the lease was released" },
{ level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
] as const;
let s = initialState;
for (const message of quietLifecycleMessages) {
s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
}
eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
}
{
const history: HistoryMessage[] = [
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
{ role: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
{ role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
{ role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
{ role: "user", content: "continue" },
];
const hydrated = historyMessagesToItems(history, "h");
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
const lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
const users = hydrated.items.filter((item) => item.kind === "user");
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
}
{
const hydrated = historyMessagesToItems([{ role: "notice", level: "warn", content: "short notice", detail: "historical diagnostic" }], "h");
const notice = hydrated.items.find((item) => item.kind === "notice" && item.text === "short notice");
eq(notice?.kind === "notice" && notice.detail, "historical diagnostic", "history notices preserve expandable detail text");
}
{
@@ -303,6 +408,12 @@ console.log("\nuse controller meta");
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
}
{
const s = reducer(initialState, { type: "event", e: { kind: "notice", level: "warn", text: "short notice", detail: "verbose diagnostic" } });
const notice = s.items.find((item) => item.kind === "notice" && item.text === "short notice");
eq(notice?.kind === "notice" && notice.detail, "verbose diagnostic", "runtime notices preserve expandable detail text");
}
{
let s = reducer(initialState, {
type: "history_page",
@@ -2,9 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import gsap from "gsap";
import { useT, type Translator } from "../lib/i18n";
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types";
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
import { DUR_FAST } from "../lib/gsapAnimations";
import {
FileReferenceMenu,
@@ -31,7 +30,9 @@ function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
}
function approvalToolLabel(tool: string, t: Translator): string {
const APPROVAL_MODE_RANK: Record<ToolApprovalMode, number> = { ask: 0, auto: 1, yolo: 2 };
export function approvalToolLabel(tool: string, t: Translator): string {
switch (tool) {
case "bash":
return t("approval.toolLabelBash");
@@ -55,6 +56,8 @@ function approvalToolLabel(tool: string, t: Translator): string {
return t("approval.toolLabelSandboxEscape");
case "plan_mode_read_only_command":
return t("approval.toolLabelPlanModeReadOnly");
case "exit_plan_mode":
return t("approval.toolLabelExitPlan");
default:
return tool;
}
@@ -122,6 +125,7 @@ export function ApprovalModal({
cwd,
insertRequest,
onRevisionActiveChange,
toolApprovalMode,
}: {
approval: WireApproval;
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
@@ -131,12 +135,22 @@ export function ApprovalModal({
cwd?: string;
insertRequest?: ComposerInsertRequest | null;
onRevisionActiveChange?: (active: boolean) => void;
toolApprovalMode?: ToolApprovalMode;
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const toolLabel = approvalToolLabel(approval.tool, t);
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
// Switching the approval segmented control to a more permissive mode does not
// resolve an already-pending request; say so on the card instead of leaving
// the user to wonder why the switch "did nothing".
const initialToolApprovalModeRef = useRef(toolApprovalMode);
const approvalModeRelaxed =
!isPlanApproval &&
toolApprovalMode !== undefined &&
initialToolApprovalModeRef.current !== undefined &&
APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current];
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
@@ -147,6 +161,9 @@ export function ApprovalModal({
const [revisionText, setRevisionText] = useState("");
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
// Action index currently hovered/focused; the consequence preview row
// prefers it over the keyboard-selected action.
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
@@ -205,7 +222,7 @@ export function ApprovalModal({
setRevisionText("");
setDetailsOpen(showToolDetailsByDefault);
setSelectedIndex(isPlanApproval ? 1 : 0);
playAttentionChime();
setHoverIndex(null);
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
@@ -384,6 +401,26 @@ export function ApprovalModal({
);
}
// Descriptor list mirrors the action buttons below; the consequence row
// previews what the hovered (or keyboard-selected) action will actually do,
// in the quick-pick style the ask card uses for option descriptions.
const toolActions: { key: string; label: string; desc: string; run: () => void }[] = [
{ key: "1", label: t("approval.allowOnce"), desc: t("approval.allowOnceDesc"), run: () => onAnswer(true, false, false) },
...(isFreshHumanApproval
? hasFreshSessionGrant
? [
{ key: "2", label: t("approval.allowSandboxEscapeSession"), desc: t("approval.allowSandboxEscapeSessionDesc"), run: () => onAnswer(true, true, false) },
{ key: "3", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
]
: [{ key: "2", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }]
: [
{ key: "2", label: t("approval.allowRuleSession"), desc: t("approval.allowRuleSessionDesc"), run: () => onAnswer(true, true, false) },
{ key: "3", label: t("approval.allowRulePersistent"), desc: t("approval.allowRulePersistentDesc"), run: () => onAnswer(true, true, true) },
{ key: "4", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
]),
];
const previewAction = toolActions[hoverIndex ?? selectedIndex] ?? null;
return (
<div ref={shelfRef}>
<PromptShelf
@@ -407,33 +444,46 @@ export function ApprovalModal({
}
actions={
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
{isFreshHumanApproval ? (
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} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
</>
)}
{toolActions.map((action, index) => (
<PromptAction
key={action.key}
keyLabel={action.key}
label={action.label}
onClick={() => answerWithExit(action.run)}
selected={selectedIndex === index}
title={action.desc}
onHoverChange={(hovering) =>
setHoverIndex((current) => (hovering ? index : current === index ? null : current))
}
/>
))}
</>
}
note={
previewAction && (
<div className="approval-consequence">
<span className="approval-consequence__label">{previewAction.label}</span>
<span className="approval-consequence__text">{previewAction.desc}</span>
</div>
)
}
>
{detailsOpen && (
<div className="approval-details">
{reason && <div className="approval-reason">{reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
{/* Guard the whole block: PromptShelf only renders its body when children
are truthy, and a fragment of two false branches would still count. */}
{(approvalModeRelaxed || detailsOpen) && (
<>
{approvalModeRelaxed && (
<div className="approval-mode-hint">{t("approval.modeSwitchPendingHint")}</div>
)}
</div>
{detailsOpen && (
<div className="approval-details">
{reason && <div className="approval-reason">{reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
</div>
)}
</>
)}
</PromptShelf>
</div>
+36 -2
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
import { PromptAction, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
// AskCard renders the `ask` tool as a compact prompt shelf near the composer. It
// walks multi-question asks one at a time; single-select answers advance
@@ -24,6 +23,8 @@ export function AskCard({
const [custom, setCustom] = useState<Record<string, string>>({});
const [customOpen, setCustomOpen] = useState(false);
const [active, setActive] = useState(0);
// Option label currently hovered/focused; drives the detail preview row.
const [hoverLabel, setHoverLabel] = useState<string | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const advanceTimer = useRef<number | null>(null);
@@ -40,8 +41,11 @@ export function AskCard({
setCustom({});
setCustomOpen(false);
setActive(0);
setHoverLabel(null);
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
playAttentionChime();
// The attention chime plays from the global runtime event stream (App.tsx),
// keyed by prompt id — not here. A mount-time chime would double-fire for
// the active tab and stay silent for background tabs.
}, [ask.id]);
useEffect(() => {
@@ -73,6 +77,7 @@ export function AskCard({
useEffect(() => {
setCustomOpen(false);
setHoverLabel(null);
}, [active]);
useEffect(() => {
@@ -156,6 +161,23 @@ export function AskCard({
[active, custom, questions, sel],
);
// Grid cells truncate descriptions to one line; this row previews the full
// description of the hovered/focused option, falling back to the latest
// selected option and then the first described option so it never blanks.
const detailOption = useMemo(() => {
if (!q) return null;
const withDescription = (label?: string | null) =>
label ? q.options.find((option) => option.label === label && option.description) : undefined;
const hovered = withDescription(hoverLabel);
if (hovered) return hovered;
const selectedLabels = sel[q.id] ?? [];
for (let i = selectedLabels.length - 1; i >= 0; i -= 1) {
const chosen = withDescription(selectedLabels[i]);
if (chosen) return chosen;
}
return q.options.find((option) => option.description) ?? null;
}, [hoverLabel, q, sel]);
if (!q) return null;
return (
@@ -196,11 +218,23 @@ export function AskCard({
description={o.description}
onClick={() => toggle(q, o.label)}
selected={on}
title={o.description || undefined}
onHoverChange={(hovering) =>
setHoverLabel((current) => (hovering ? o.label : current === o.label ? null : current))
}
/>
);
})}
</>
}
note={
detailOption && (
<div className="ask-shelf__detail">
<span className="ask-shelf__detail-label">{detailOption.label}</span>
<span className="ask-shelf__detail-text">{detailOption.description}</span>
</div>
)
}
quickActions={
<>
{active > 0 && (
+115 -33
View File
@@ -51,6 +51,9 @@ const LONG_PASTE_MIN_CHARS = 2000;
const LONG_PASTE_MIN_LINES = 20;
const COMPOSER_MIN_HEIGHT = 104;
const COMPOSER_MAX_HEIGHT = 360;
// Height reserved for the in-card run strip while a turn runs; applied via a
// CSS calc so --composer-height always stays in "logical height" space.
const COMPOSER_RUN_STRIP_RESERVED = 30;
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
@@ -255,6 +258,14 @@ function composerMaxHeight(): number {
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
}
// The rendered card includes the run strip while a turn runs; subtract it to
// recover the user's logical height when measuring from the DOM.
function composerLogicalHeight(card: HTMLElement): number {
const strip = card.querySelector(".composer-run-strip");
const stripHeight = strip ? strip.getBoundingClientRect().height : 0;
return card.getBoundingClientRect().height - stripHeight;
}
function clampComposerHeight(height: number): number {
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
}
@@ -449,6 +460,8 @@ export function Composer({
turnStartAt,
turnTokens,
retry,
pendingApprovalLabel,
pendingAsk = false,
transientDismissSignal,
sessionKey,
fileRefRefreshKey,
@@ -492,6 +505,11 @@ export function Composer({
turnStartAt?: number;
turnTokens?: number;
retry?: { attempt: number; max: number };
// Resolved label of the tool waiting for approval (null/undefined when none);
// shifts the run strip into its waiting state so the ticking spinner does not
// claim the model is working while a prompt is blocked on the user.
pendingApprovalLabel?: string | null;
pendingAsk?: boolean;
transientDismissSignal?: number;
sessionKey?: string;
fileRefRefreshKey?: number | string;
@@ -1536,7 +1554,19 @@ export function Composer({
const handleCancel = () => {
const restored = onCancel();
if (goalModeOn && activeGoal) onClearGoal();
if (typeof restored === "string") setTextCaretEnd(restored);
// Queued guidance the model never consumed would otherwise vanish when the
// cancelled turn ends (the running→false effect clears the shelf). Fold it
// back into the draft: cancelling means "stop acting", not "discard what I
// typed" — the same contract onCancel already honors for un-sent text.
const queued = pendingGuidance.map((item) => item.text).filter((part) => part.trim() !== "");
if (queued.length === 0) {
if (typeof restored === "string") setTextCaretEnd(restored);
return;
}
setPendingGuidance([]);
setGuidanceExpanded(false);
const base = typeof restored === "string" ? restored : text;
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
};
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
@@ -1637,7 +1667,7 @@ export function Composer({
e.preventDefault();
const startY = e.clientY;
const startHeight = composerHeight ?? card.getBoundingClientRect().height;
const startHeight = composerHeight ?? composerLogicalHeight(card);
let nextHeight = clampComposerHeight(startHeight);
let moved = false;
card.style.setProperty("--composer-height", `${nextHeight}px`);
@@ -1675,7 +1705,7 @@ export function Composer({
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const card = composerCardRef.current;
const current = composerHeight ?? card?.getBoundingClientRect().height ?? COMPOSER_MIN_HEIGHT;
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
const step = e.shiftKey ? 32 : 16;
let next: number | null = null;
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
@@ -2006,7 +2036,18 @@ export function Composer({
}
};
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
// When the run strip is visible inside a user-resized card, the card grows
// by the strip's reserved height so the meta row stays fully visible.
// --composer-height carries only the user's logical height; the reservation
// is a separate variable consumed by the CSS calc, so the live resize drag
// (which writes raw logical heights) stays consistent with this render path.
const showRunStrip = Boolean(retry || running);
const composerCardStyle = composerHeight === null
? undefined
: ({
"--composer-height": `${composerHeight}px`,
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
} as CSSProperties);
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
: undefined;
@@ -2054,17 +2095,45 @@ export function Composer({
requestAnimationFrame(() => taRef.current?.focus());
});
};
const runActivity = retry
// Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
// The waiting states replace the whimsical ticker — while a prompt is blocked
// on the user, the strip must say so instead of counting "working" seconds.
const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null;
// The pending approval itself disables the composer; keep the approval bar
// usable in exactly that case so the mode can still be changed mid-prompt.
const approvalBarDisabled = Boolean(disabled) && !pendingApprovalLabel;
// Waiting on the user (approval/ask) is not model work: pause the ticker's
// clock while a prompt is blocked so the elapsed time means "model time",
// matching what the waiting strip promises. Retries keep counting — they are
// system time, not user time. Accumulated in state via the effect cleanup so
// leaving the waiting state re-renders with the corrected time immediately.
const [waitAccumMs, setWaitAccumMs] = useState(0);
useEffect(() => {
setWaitAccumMs(0);
}, [turnStartAt]);
useEffect(() => {
if (!waitingPrompt) return;
const since = Date.now();
return () => setWaitAccumMs((total) => total + (Date.now() - since));
}, [waitingPrompt]);
const runStateText = retry
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
: running && turnStartAt
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
: waitingPrompt === "approval"
? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
: waitingPrompt === "ask"
? t("composer.runWaitingAsk")
: running
? t("composer.runAnnounceRunning")
: null;
const runTicker = !retry && !waitingPrompt && running && turnStartAt
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
@@ -2322,20 +2391,6 @@ export function Composer({
/>
)
)}
{runActivity && (
<div className="composer-toolbar composer-toolbar--status-only">
<div className="composer-runstatus" role="status" aria-live="polite">
<span className="composer-runstatus__dot" />
<span className="composer-runstatus__text">{runActivity}</span>
<Tooltip label={t("composer.stop")}>
<button className="composer-runstatus__stop" type="button" onClick={handleCancel}>
<Square size={10} fill="currentColor" />
<span>{t("composer.stopShort")}</span>
</button>
</Tooltip>
</div>
</div>
)}
{pendingGuidance.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
@@ -2484,7 +2539,7 @@ export function Composer({
</div>
)}
<div
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
ref={composerCardRef}
style={composerCardStyle}
>
@@ -2502,6 +2557,17 @@ export function Composer({
onKeyDown={onComposerResizeKeyDown}
onDoubleClick={resetComposerHeight}
/>
{runStateText && (
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
<span className="composer-run-strip__dot" aria-hidden="true" />
{/* The ticker re-renders every second; keep it out of the accessibility
tree and announce only the stable state text via the live region. */}
<span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
{runTicker ?? runStateText}
</span>
<span className="sr-only" role="status">{runStateText}</span>
</div>
)}
<div
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
onDrop={onDrop}
@@ -2543,6 +2609,18 @@ export function Composer({
{composerPrompt}
</span>
)}
{running && (
<Tooltip label={t("composer.stop")}>
<button
className="composer__btn composer__btn--stop"
type="button"
onClick={handleCancel}
aria-label={t("composer.stop")}
>
<Square size={12} fill="currentColor" />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
@@ -2550,7 +2628,7 @@ export function Composer({
disabled={submitBlocked}
aria-label={submitTooltip}
>
<ArrowUp size={16} />
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
</button>
</Tooltip>
</div>
@@ -2634,13 +2712,17 @@ export function Composer({
)}
</div>
<div className="composer-meta__control composer-meta__control--approval">
{/* A pending tool approval disables the composer, but the approval
bar stays usable so mode changes remain possible mid-prompt;
the approval card explains that the pending request still needs
an explicit decision. */}
<div className="composer-modebar composer-modebar--approval" data-mode={toolApprovalMode} title={t("composer.accessMenuTitle")}>
<span className="composer-modebar__thumb" aria-hidden="true" />
<button
type="button"
className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("ask")}
disabled={disabled}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "ask"}
title={t("composer.accessAskTitle")}
>
@@ -2651,7 +2733,7 @@ export function Composer({
type="button"
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("auto")}
disabled={disabled}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "auto"}
title={t("composer.accessAutoTitle")}
>
@@ -2662,7 +2744,7 @@ export function Composer({
type="button"
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("yolo")}
disabled={disabled}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "yolo"}
title={t("composer.accessYoloTitle")}
>
@@ -28,6 +28,7 @@ export function HistoryPanel({
onRestore,
onPurge,
onPurgeAll,
onDeleteMany,
onClose,
}: {
kind?: "history" | "trash";
@@ -40,6 +41,7 @@ export function HistoryPanel({
onRestore?: (path: string) => void;
onPurge?: (path: string) => void;
onPurgeAll?: (paths: string[]) => void;
onDeleteMany?: (paths: string[]) => void;
onClose: () => void;
}) {
const tr = useT();
@@ -56,7 +58,7 @@ export function HistoryPanel({
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | { kind: "clearRecovery" } | null
>(null);
const [preview, setPreview] = useState<{
path: string;
@@ -144,15 +146,33 @@ export function HistoryPanel({
return [s.title, s.preview, s.path, s.topicTitle, s.workspaceRoot].some((part) => (part ?? "").toLowerCase().includes(q));
});
}, [dateFilter, isTrash, query, scopeFilter, sessions, statusFilter]);
// Recovery copies are bulk-actionable in both views: trash purges them for
// good, history sweeps them into the trash. The history sweep skips copies
// that are current or open in a tab — an adopted copy is someone's live
// conversation. Counting from `sessions` (not the filtered list) keeps the
// sweep exhaustive even while a search or filter is active.
const recoveryCopyPaths = useMemo(
() => sessions.filter((s) => s.recovered && (isTrash || (!s.current && !s.open))).map((s) => s.path),
[isTrash, sessions],
);
const recoveryCopyCount = recoveryCopyPaths.length;
const displayedSessions = useMemo(
() =>
isTrash
? filteredSessions
: [...filteredSessions.filter((s) => !s.recovered), ...filteredSessions.filter((s) => s.recovered)],
[filteredSessions, isTrash],
);
// Sessions arrive newest-first; bucket consecutive ones under a day heading
// (Today / Yesterday / a date) while preserving that order.
const groups: { label: string; items: SessionMeta[] }[] = [];
for (const s of filteredSessions) {
const groups: { label: string; items: SessionMeta[]; recovered: boolean }[] = [];
for (const s of displayedSessions) {
const recovered = !isTrash && Boolean(s.recovered);
const label = dayLabel(sessionTimeForGrouping(s, isTrash));
const last = groups[groups.length - 1];
if (last && last.label === label) last.items.push(s);
else groups.push({ label, items: [s] });
if (last && last.label === label && last.recovered === recovered) last.items.push(s);
else groups.push({ label, items: [s], recovered });
}
useEffect(() => {
@@ -168,19 +188,19 @@ export function HistoryPanel({
useEffect(() => {
setEditing(null);
if (filteredSessions.length === 0) {
if (displayedSessions.length === 0) {
if (preview) setPreview(null);
return;
}
if (preview && filteredSessions.some((s) => s.path === preview.path)) return;
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
if (preview && displayedSessions.some((s) => s.path === preview.path)) return;
const first = displayedSessions.find((s) => !s.current) ?? displayedSessions[0];
void loadPreview(first);
}, [filteredSessions, loadPreview, preview]);
}, [displayedSessions, loadPreview, preview]);
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
const selectedSession = useMemo(
() => (preview ? filteredSessions.find((s) => s.path === preview.path) ?? null : null),
[filteredSessions, preview],
() => (preview ? displayedSessions.find((s) => s.path === preview.path) ?? null : null),
[displayedSessions, preview],
);
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
event.preventDefault();
@@ -207,6 +227,13 @@ export function HistoryPanel({
setBlankMenuPoint(null);
setMenuConfirmTarget({ kind: "clear" });
};
const armClearRecoveryCopies = () => {
if (recoveryCopyCount === 0 || (!isTrash && running)) return;
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(null);
setMenuConfirmTarget({ kind: "clearRecovery" });
};
const closeHistoryMenus = () => {
setMenuSession(null);
setMenuPoint(null);
@@ -226,6 +253,12 @@ export function HistoryPanel({
closeHistoryMenus();
onPurgeAll?.(paths);
};
const clearRecoveryCopies = () => {
const paths = recoveryCopyPaths;
closeHistoryMenus();
if (isTrash) onPurgeAll?.(paths);
else onDeleteMany?.(paths);
};
const sessionMenuItems: ContextMenuItem[] = menuSession
? isTrash
? [
@@ -292,30 +325,52 @@ export function HistoryPanel({
]
: [];
const trashBlankMenuItems: ContextMenuItem[] =
menuConfirmTarget?.kind === "clear"
menuConfirmTarget?.kind === "clearRecovery"
? [
{
key: "clear-trash-confirm",
key: "clear-recovery-confirm",
icon: <Trash2 size={13} />,
label: tr("history.confirmClearTrash"),
label: tr("history.confirmClearRecoveryCopies"),
danger: true,
onSelect: clearTrash,
onSelect: clearRecoveryCopies,
},
]
: [
{
key: "clear-trash",
icon: <Trash2 size={13} />,
label: tr("history.clearTrashMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
},
];
: menuConfirmTarget?.kind === "clear"
? [
{
key: "clear-trash-confirm",
icon: <Trash2 size={13} />,
label: tr("history.confirmClearTrash"),
danger: true,
onSelect: clearTrash,
},
]
: [
...(recoveryCopyCount > 0
? [
{
key: "clear-recovery",
icon: <Trash2 size={13} />,
label: tr("history.clearRecoveryCopiesMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clearRecovery" }),
} as ContextMenuItem,
]
: []),
{
key: "clear-trash",
icon: <Trash2 size={13} />,
label: tr("history.clearTrashMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
},
];
const actionConfirmDelete =
selectedSession && menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === selectedSession.path;
const actionConfirmPurge =
selectedSession && menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === selectedSession.path;
const actionConfirmClearTrash = isTrash && menuConfirmTarget?.kind === "clear";
const actionConfirmClearRecovery = menuConfirmTarget?.kind === "clearRecovery";
const openSelected = () => {
if (!selectedSession || running || isTrash) return;
@@ -356,6 +411,18 @@ export function HistoryPanel({
{!isTrash && running && <div className="management-modal__summary history-modal__summary">{tr("history.readOnlyHint")}</div>}
</div>
<div className="management-modal__actions history-modal__actions">
{recoveryCopyCount > 0 && (
<button
className={`chip history-clear${actionConfirmClearRecovery ? " history-clear--confirm" : ""}`}
type="button"
disabled={!isTrash && running}
onClick={actionConfirmClearRecovery ? clearRecoveryCopies : armClearRecoveryCopies}
>
{isTrash
? tr(actionConfirmClearRecovery ? "history.confirmClearRecoveryCopies" : "history.clearRecoveryCopies")
: tr(actionConfirmClearRecovery ? "history.confirmTrashRecoveryCopies" : "history.trashRecoveryCopies")}
</button>
)}
{isTrash && sessions.length > 0 && (
<button
className={`chip history-clear${actionConfirmClearTrash ? " history-clear--confirm" : ""}`}
@@ -426,9 +493,9 @@ export function HistoryPanel({
<div className="mem-empty">{tr("history.noResults")}</div>
) : (
groups.map((g) => (
<section className="mem-section" key={g.label}>
<section className="mem-section" key={`${g.recovered ? "recovered" : "normal"}-${g.label}`}>
<div className="mem-section__title hist-group__title">
<span>{g.label}</span>
<span>{g.recovered ? `${tr("history.recoveryCopiesGroup")} · ${g.label}` : g.label}</span>
<span className="hist-group__count">{g.items.length}</span>
</div>
{g.items.map((s) => {
@@ -10,6 +10,7 @@ export function PromptShelf({
actions,
children,
crumbs,
note,
quickActions,
headerActions,
barRef,
@@ -24,6 +25,9 @@ export function PromptShelf({
actions?: ReactNode;
children?: ReactNode;
crumbs?: ReactNode;
// Rendered between the actions grid and the quick actions; used for
// focus-following detail previews and similar footnotes.
note?: ReactNode;
quickActions?: ReactNode;
headerActions?: ReactNode;
barRef?: RefObject<HTMLDivElement | null>;
@@ -52,6 +56,7 @@ export function PromptShelf({
{crumbs}
{children && <div className="prompt-shelf__body">{children}</div>}
{actions && <div className="prompt-shelf__actions">{actions}</div>}
{note && <div className="prompt-shelf__footnote">{note}</div>}
{quickActions && <div className="prompt-shelf__quick-actions">{quickActions}</div>}
</div>
</div>
@@ -92,6 +97,8 @@ export function PromptAction({
description,
onClick,
ariaLabel,
title,
onHoverChange,
primary = false,
selected = false,
quiet = false,
@@ -102,6 +109,11 @@ export function PromptAction({
description?: ReactNode;
onClick: () => void;
ariaLabel?: string;
// Native tooltip fallback for truncated descriptions.
title?: string;
// Fires on mouse enter/focus (true) and mouse leave/blur (false) so the
// parent can drive a focus-following detail preview.
onHoverChange?: (hovering: boolean) => void;
primary?: boolean;
selected?: boolean;
quiet?: boolean;
@@ -121,6 +133,11 @@ export function PromptAction({
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
title={title}
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
onFocus={onHoverChange ? () => onHoverChange(true) : undefined}
onBlur={onHoverChange ? () => onHoverChange(false) : undefined}
>
{keyLabel && <span className="prompt-action__key">{keyLabel}</span>}
{hasCopy && (
+68 -6
View File
@@ -16,6 +16,8 @@ const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "rev
/** Lines shown by default in a shell output block before the "show all" button. */
const SHELL_PREVIEW_LINES = 10;
const ERROR_SUMMARY_MAX_CHARS = 140;
const ERROR_DETAILS_THRESHOLD = 220;
function pretty(json: string): string {
try {
@@ -30,6 +32,41 @@ function formatToolDuration(ms?: number): string {
return `${Math.round(ms)} ms`;
}
function normalizeErrorText(text: string): string {
return text.replace(/\r\n/g, "\n").trim();
}
function withoutErrorPrefix(text: string): string {
return normalizeErrorText(text).replace(/^error:\s*/i, "");
}
function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
if (!output || !error) return false;
const normalizedOutput = normalizeErrorText(output);
const normalizedError = normalizeErrorText(error);
if (!normalizedOutput || !normalizedError) return false;
return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
}
function summarizeToolError(error: string, receiptMismatchText: string): string {
const text = withoutErrorPrefix(error);
if (!text) return "";
if (/has no matching successful receipt/i.test(text)) {
return receiptMismatchText;
}
const firstLine = text.split("\n")[0]?.trim() ?? "";
if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
}
function errorNeedsDetails(error: string, summary: string): boolean {
const normalizedError = withoutErrorPrefix(error);
if (!normalizedError) return false;
return normalizedError.includes("\n") ||
normalizedError.length > ERROR_DETAILS_THRESHOLD ||
(summary !== "" && normalizedError !== summary);
}
/** Returns the first n lines of text and the total line count. */
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
const lines = text.split("\n");
@@ -60,12 +97,14 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
const openRef = useRef(open);
openRef.current = open;
const [showAll, setShowAll] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
// Lazy-load full tool data from the backend when the card is expanded and
// the in-memory copy was archived for memory efficiency.
const [fullData, setFullData] = useState<{ args: string; output?: string } | null>(null);
const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
const effectiveOutput = fullData?.output ?? item.output;
const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
@@ -78,12 +117,15 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
// else folds its args/output away by default. Open while running so the
// user sees progress; closed by default once settled.
const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!effectiveOutput || hasArchivedOnDemandBody);
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
// Shell output: split into preview + "show all" toggle.
const shellOutput = item.isShell && effectiveOutput ? effectiveOutput : null;
const shellOutput = item.isShell && displayOutput ? displayOutput : null;
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error);
const errorText = item.error ? normalizeErrorText(item.error) : "";
const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
useEffect(() => {
if (!open || !item.dataArchived || fullData || !tabId) return;
let cancelled = false;
@@ -111,7 +153,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
const duration = item.status === "running" ? "" : formatToolDuration(item.durationMs);
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, effectiveOutput, item.error));
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? errorSummary : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error));
// GSAP-driven collapse/expand for tool body
const toolBodyRef = useRef<HTMLDivElement>(null);
@@ -207,16 +249,36 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
{!shellPreview && hasArgsOrOutput && (
<>
{effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
{effectiveOutput && (
{displayOutput && (
<>
<CodeViewer value={effectiveOutput} maxHeight={280} />
<CodeViewer value={displayOutput} maxHeight={280} />
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
</>
)}
</>
)}
{item.error && <div className="tool__err">{item.error}</div>}
{errorText && (
<div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
{hasErrorDetails ? (
<>
<div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
<button
type="button"
className="tool__err-toggle"
onClick={() => setShowErrorDetails((value) => !value)}
aria-expanded={showErrorDetails}
>
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
<span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
</button>
{showErrorDetails && <div className="tool__err-details">{errorText}</div>}
</>
) : (
errorText
)}
</div>
)}
</div>
</div>
);
+13 -4
View File
@@ -587,7 +587,7 @@ export function Transcript({
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
break;
case "phase": out.push(<PhaseCard key={it.id} text={it.text} />); break;
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
}
}
@@ -949,7 +949,7 @@ function WarmTurnItems({
break;
}
case "phase": nodes.push(<PhaseCard key={it.id} text={it.text} />); break;
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
}
}
@@ -1268,11 +1268,20 @@ function PhaseCard({ text }: { text: string }) {
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
}
function NoticeCard({ level, text }: { level: NoticeItem["level"]; text: string }) {
function NoticeCard({ level, text, detail }: { level: NoticeItem["level"]; text: string; detail?: string }) {
const t = useT();
return (
<div className={`notice-line notice-line--${level}`} data-entrance="true">
<span className="notice-line__icon">{level === "warn" ? "⚠ " : "ℹ "}</span>
<span className="notice-line__text">{text}</span>
<div className="notice-line__text">
{text}
{detail ? (
<details className="notice-line__details">
<summary>{t("notice.details")}</summary>
<div>{detail}</div>
</details>
) : null}
</div>
</div>
);
}
+57 -10
View File
@@ -574,6 +574,16 @@ export function onFilesDropped(cb: (paths: string[]) => void): () => void {
// onReady subscribes to the agent:ready event fired when boot.Build completes.
// The frontend re-fetches Meta/Context/History when this lands.
// onRuntimeRebuilt fires when a tab's controller is replaced in place
// (model/effort/token-mode switch, clear-while-running). The rebuilt
// controller restarts prompt ids, so per-tab id-keyed state must reset.
export function onRuntimeRebuilt(cb: (tabId?: string) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("runtime:rebuilt", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
}
return () => {};
}
export function onReady(cb: (tabId?: string) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("agent:ready", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
@@ -627,6 +637,11 @@ function bridgeBreadcrumb(method: string): string {
return "";
}
function elapsedMs(startedAt: number): number {
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
return Math.max(0, Math.round(now - startedAt));
}
export const app: AppBindings = new Proxy({} as AppBindings, {
get(_t, prop) {
const target = realApp() ?? getMock();
@@ -635,18 +650,26 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
return (...args: unknown[]) => {
const method = String(prop);
const crumb = bridgeBreadcrumb(method);
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
if (crumb) addBreadcrumb("bridge", crumb);
try {
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
if (result && typeof (result as Promise<unknown>).then === "function") {
return (result as Promise<unknown>).catch((err) => {
if (crumb) addBreadcrumb("bridge.error", method);
throw err;
});
return (result as Promise<unknown>).then(
(value) => {
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
return value;
},
(err) => {
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
throw err;
},
);
}
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
return result;
} catch (err) {
if (crumb) addBreadcrumb("bridge.error", method);
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
throw err;
}
};
@@ -719,13 +742,14 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
return "demo";
}
@@ -820,8 +844,8 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
@@ -874,6 +898,7 @@ function makeMockApp(): AppBindings {
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
const sandboxEscapeMock = scenario === "sandbox_escape";
const noticePreviewMock = scenario === "notice";
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
let pendingAskPreview = false;
@@ -982,7 +1007,7 @@ function makeMockApp(): AppBindings {
// Mutable so delete/rename are observable in browser dev.
const sessions: SessionMeta[] = [
{ path: "/mock/sessions/a.jsonl", preview: "fix the login bug in auth.go", turns: 12, createdAt: t0 - 2 * day, lastActivityAt: t0 - 3_600_000, modTime: t0 - 3_600_000, current: true, open: true },
{ path: "/mock/sessions/b.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true },
{ path: "/mock/sessions/b-recovery-0123456789abcdef.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true, recovered: true },
{ path: "/mock/sessions/c.jsonl", preview: "write the README and badges", turns: 8, createdAt: t0 - 4 * day, lastActivityAt: t0 - day - 3_600_000, modTime: t0 - day - 3_600_000, current: false, open: false },
{ path: "/mock/sessions/d.jsonl", preview: "explain the plugin host design", turns: 3, createdAt: t0 - 5 * day, lastActivityAt: t0 - 4 * day, modTime: t0 - 4 * day, current: false, open: false },
];
@@ -1033,6 +1058,7 @@ function makeMockApp(): AppBindings {
scope: "global",
topicId: "topic_product",
topicTitle: t("mock.trashGlobalProductTitle"),
recovered: true,
},
];
if (freshMock) {
@@ -1557,7 +1583,28 @@ function makeMockApp(): AppBindings {
setMockTabRunning(currentMockTurnTabId(), false);
emit({ kind: "turn_done" });
};
let mockTabs: TabMeta[] = freshMock ? [
let mockTabs: TabMeta[] = noticePreviewMock ? [
{
id: "tab_notice_preview",
scope: "project",
workspaceRoot: "~/projects/reasonix",
workspaceName: "reasonix",
workspacePath: "~/projects/reasonix",
gitBranch: "codex/compact-chat-notices-i18n",
topicId: "topic_notice_preview",
topicTitle: "Compact notice preview",
projectColor: "green",
label: "DeepSeek-R1",
ready: true,
running: false,
mode: "normal",
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "~/projects/reasonix",
},
] : freshMock ? [
{
id: "tab_global",
scope: "global",
+11 -1
View File
@@ -635,6 +635,7 @@ export function installPerformancePressureMonitor() {
const isFocused = () => typeof document === "undefined" || document.hasFocus?.() !== false;
let visibleSince = isHidden() ? Number.POSITIVE_INFINITY : startedAt;
let expected = performance.now() + 1000;
let eventLoopLagPrimed = false;
const pastGrace = () => performance.now() >= graceUntil;
const inspectLongTasks = () => {
@@ -651,6 +652,7 @@ export function installPerformancePressureMonitor() {
longTasks.length = 0;
lagSamples.length = 0;
expected = performance.now() + 1000;
eventLoopLagPrimed = false;
if (!isHidden()) visibleSince = performance.now();
});
}
@@ -673,9 +675,17 @@ export function installPerformancePressureMonitor() {
window.setInterval(() => {
const now = performance.now();
if (!pastGrace()) {
expected = now + 1000;
return;
}
if (!eventLoopLagPrimed) {
expected = now + 1000;
eventLoopLagPrimed = true;
return;
}
const lagMs = Math.max(0, now - expected);
expected = now + 1000;
if (!pastGrace()) return;
if (!shouldRecordEventLoopLagSample(isHidden(), now - visibleSince, isFocused())) return;
lagSamples.push(lagMs);
if (lagSamples.length > MAX_LAG_SAMPLES) lagSamples.shift();
+1 -1
View File
@@ -114,7 +114,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
}, [locale]);
// setPref updates only the live UI; persistence is handled by desktop config.
+53
View File
@@ -156,3 +156,56 @@ export function playAttentionChime(): void {
void playWav(pref, 0.25, playSynthAttention);
}
}
export type AttentionChimeEvent = {
kind?: string;
tabId?: string;
approval?: { id?: string };
ask?: { id?: string };
};
export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined {
if (event.kind === "approval_request" && event.approval?.id) return `approval:${event.tabId ?? ""}:${event.approval.id}`;
if (event.kind === "ask_request" && event.ask?.id) return `ask:${event.tabId ?? ""}:${event.ask.id}`;
return undefined;
}
// attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per
// prompt, so the set only ever grows; past the cap the oldest half is dropped
// (insertion order) — replay dedupe only needs to cover recently replayed
// prompts, not the whole session history.
const attentionChimeSeenCap = 512;
// clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval
// and ask ids are per-controller counters starting at "1", so a rebuilt
// controller (model/effort/settings switch) reissues ids an earlier prompt on
// the same tab already used — without this, the first prompt after a rebuild
// is misread as a replay and stays silent. A ready event without a tab id
// (settings rebuilds emit tab-less ready) clears everything: over-clearing
// only re-chimes a replayed pending prompt, which is a desirable reminder,
// while under-clearing mutes a live prompt.
export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void {
if (tabId === undefined || tabId === "") {
seen.clear();
return;
}
for (const key of [...seen]) {
if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) {
seen.delete(key);
}
}
}
export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean {
const key = attentionChimeEventKey(event);
if (!key || seen.has(key)) return false;
if (seen.size >= attentionChimeSeenCap) {
let drop = seen.size - attentionChimeSeenCap / 2;
for (const k of seen) {
if (drop-- <= 0) break;
seen.delete(k);
}
}
seen.add(key);
return true;
}
+2
View File
@@ -155,6 +155,7 @@ export interface MemoryCompilerStats {
export interface WireEvent {
kind: EventKind;
text?: string;
detail?: string;
reasoning?: string;
memoryCitations?: MemoryCitation[];
memoryCompiler?: MemoryCompilerStats;
@@ -324,6 +325,7 @@ export interface ChangedFileInfo {
export interface HistoryMessage {
role: string;
content: string;
detail?: string;
submitText?: string;
checkpointTurn?: number;
createdAt?: number;
+228 -10
View File
@@ -53,7 +53,7 @@ export type Item =
| { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] }
| { kind: "phase"; id: string; text: string }
| { kind: "notice"; id: string; level: "info" | "warn"; text: string }
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string }
| {
kind: "compaction";
id: string;
@@ -244,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
const STALE_TURN_RECONCILE_MS = 30_000;
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
const STARTUP_READY_META_RECONCILE_MS = 250;
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
export function shouldReconcileStaleTurn(
state: Pick<State, "running" | "turnActive"> | undefined,
@@ -379,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
const positionalResults = positionalToolResults(messages);
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
const items: Item[] = [];
let items: Item[] = [];
let seq = startSeq;
const consumedToolIDs = new Set<string>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
@@ -394,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
}
if (m.role === "notice") {
if (m.content.trim() !== "") {
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: localizedBackendNoticeText(m.content) });
seq++;
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content, m.detail);
items = next.items;
seq = next.seq;
}
continue;
}
@@ -725,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
}
case "notice":
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: localizedBackendNoticeText(e.text ?? "") }] };
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail);
case "phase":
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
case "compaction_started":
@@ -1009,9 +1012,169 @@ export function localizedBackendNoticeText(text: string): string {
if (modelFallback) {
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
}
const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
if (backgroundJob) {
return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
}
const canonicalNoticeKey = backendNoticeKey(msg);
if (canonicalNoticeKey) {
return t(canonicalNoticeKey);
}
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
) {
return t("recovery.noticeSavedCopy");
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
) {
return t("recovery.noticeKeptCurrent");
}
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
return t("recovery.noticeAdoptedCovered");
}
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
return t("recovery.noticeAdopted");
}
return msg;
}
function backendNoticeKey(msg: string): DictKey | "" {
switch (msg) {
case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.":
return "notice.finalReadiness";
case "No visible answer was produced; asking the assistant to respond again.":
return "notice.emptyFinal";
case "The assistant answered before taking action; asking it to use the required tools.":
return "notice.executorHandoff";
case "Tool round limit reached; asking the assistant to summarize progress.":
return "notice.toolBudget";
case "The assistant is stuck retrying a blocked action; asking it to change approach.":
return "notice.loopGuard";
case "Context is getting large; preserving cache until cleanup is needed.":
return "notice.contextLarge";
case "Context cleanup skipped for now.":
return "notice.contextCleanupSkipped";
case "Automatic context cleanup paused because the context window is too small.":
return "notice.contextCleanupPaused";
case "Context was compacted without a generated summary.":
return "notice.compactionNoSummary";
case "Planning mode enabled for this multi-step task.":
return "notice.autoPlanEnabled";
case "Plan detection requested a plan.":
return "notice.autoPlanRequested";
case "Plan detection was uncertain; using the fallback planner heuristic.":
return "notice.autoPlanFallback";
case "Goal is not ready to complete yet; continuing the remaining work.":
return "notice.goalNotReady";
case "Goal still has unfinished task state; continuing the remaining work.":
return "notice.goalUnfinished";
case "AutoResearch status update failed.":
return "notice.autoresearchStatusFailed";
case "AutoResearch task marked blocked.":
return "notice.autoresearchBlocked";
case "Job artifact migration failed.":
return "notice.jobArtifactMigrationFailed";
case "Background job teardown timed out.":
return "notice.jobTeardownTimeout";
case "Some plan-mode tool settings were ignored.":
return "notice.planModeToolSettingsIgnored";
case "Some plan-mode command settings were ignored.":
return "notice.planModeCommandSettingsIgnored";
case "Config migration did not complete.":
return "notice.configMigrationIncomplete";
case "Selected model is missing its API key.":
return "notice.modelMissingApiKey";
case "An MCP server failed to start.":
return "notice.mcpServerFailed";
case "Some MCP servers failed to start; run /mcp for details.":
return "notice.mcpServersFailed";
case "Guardian was disabled because its model was not found.":
return "notice.guardianModelMissing";
case "Guardian was disabled because it could not start.":
return "notice.guardianStartFailed";
default:
return "";
}
}
function recoveryNoticeDedupeKey(text: string): string {
const msg = text.trim();
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
msg === t("recovery.noticeSavedCopy")
) {
return "recovery:saved-copy";
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
msg === t("recovery.noticeKeptCurrent")
) {
return "recovery:kept-current";
}
if (
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
msg === t("recovery.noticeAdoptedCovered")
) {
return "recovery:adopted-covered";
}
if (
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
msg === t("recovery.noticeAdopted")
) {
return "recovery:adopted";
}
return "";
}
function quietTranscriptNoticeKey(text: string): string {
const recovery = recoveryNoticeDedupeKey(text);
if (recovery) return recovery;
const msg = text.trim();
if (/^guardian enabled · model=.+$/i.test(msg)) {
return "startup:guardian-enabled";
}
if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
return "startup:mcp-failures";
}
const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
if (directMCPFailure) {
const name = directMCPFailure[1].toLowerCase();
if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
return "startup:mcp-failure";
}
}
if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
return "startup:plugin-demote";
}
if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
return "settings:deferred-refresh-applied";
}
return "";
}
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string): { items: Item[]; seq: number } {
if (quietTranscriptNoticeKey(rawText)) {
return { items, seq };
}
const text = localizedBackendNoticeText(rawText);
if (quietTranscriptNoticeKey(text)) {
return { items, seq };
}
const trimmedDetail = detail?.trim();
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}) }], seq: seq + 1 };
}
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string): State {
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail);
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
}
function localizedSessionAction(action: string): string {
switch (action.trim()) {
case "changing model":
@@ -1075,6 +1238,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
}
}
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
try {
const meta = await app.MetaForTab(tabId);
dispatchTo(tabId, { type: "meta", meta });
return meta;
} catch {
return undefined;
}
}
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
if (!activeTabId) return;
void replay().catch(() => {});
@@ -1096,6 +1269,8 @@ export function useController() {
const stateRef = useRef(activeState);
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
const readyMetaReconcileSeq = useRef(0);
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
activeTabIdRef.current = activeTabId;
stateRef.current = activeState;
@@ -1482,11 +1657,9 @@ export function useController() {
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
return;
}
if (activeId) {
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
return;
}
void syncActiveTabFromBackend(false, true);
// A ready event can race the initial hydrate. Refresh the tab metadata
// first so a stale ready=false snapshot does not keep the composer locked.
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
});
void syncActiveTabFromBackend(false, true);
@@ -1507,6 +1680,51 @@ export function useController() {
};
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
// If the startup ready event is missed, keep the composer lock in sync with
// the active tab's backend metadata without kicking off tab activation work.
useEffect(() => {
const tabId = activeTabId;
const meta = activeState.meta;
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
readyMetaReconcileSeq.current += 1;
readyMetaReconcileActive.current = undefined;
return;
}
let cancelled = false;
let timer: number | undefined;
const seq = readyMetaReconcileSeq.current + 1;
readyMetaReconcileSeq.current = seq;
readyMetaReconcileActive.current = { tabId, seq };
const stillCurrent = () => {
const active = readyMetaReconcileActive.current;
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
};
const schedule = (attempt: number) => {
timer = window.setTimeout(() => {
void tick(attempt);
}, STARTUP_READY_META_RECONCILE_MS);
};
const tick = async (attempt: number) => {
if (!stillCurrent()) return;
const current = statesRef.current.get(tabId);
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
if (!stillCurrent()) return;
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
schedule(attempt + 1);
};
schedule(0);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
// Stale-turn watchdog: if the frontend thinks the agent is running but the
// turn stream has gone quiet, reconcile with the backend. This catches cases
// where the Wails event channel silently drops turn_done after the final
+53 -9
View File
@@ -471,7 +471,10 @@ export const en = {
"composer.send": "Send (Enter)",
"composer.queueGuidance": "Add to guidance queue (Enter)",
"composer.steer": "Add guidance to current turn (Enter)",
"composer.steerPlaceholder": "Add guidance to the queue...",
"composer.steerPlaceholder": "Running — type guidance, Enter adds it to the queue",
"composer.runWaitingApproval": "Waiting for your approval — {tool}",
"composer.runWaitingAsk": "Waiting for your answer",
"composer.runAnnounceRunning": "Reasonix is working",
"composer.guidanceQueue": "Queued guidance",
"composer.guidanceCount": "Queued guidance {n}",
"composer.guidanceRemaining": "{n} more queued",
@@ -673,6 +676,13 @@ export const en = {
"approval.sendRevision": "Send update",
"approval.exitPlan": "Exit plan",
"approval.toolPending": "Allow tool call",
"approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.",
"approval.toolLabelExitPlan": "Plan approval",
"approval.allowOnceDesc": "Allow this call only; the next one asks again.",
"approval.allowRuleSessionDesc": "Allow matching calls until this session ends; resets on restart.",
"approval.allowRulePersistentDesc": "Save a persistent allow rule; future sessions stop asking too.",
"approval.denyDesc": "Reject this call; the model sees the refusal and continues.",
"approval.allowSandboxEscapeSessionDesc": "Run follow-up commands in the real environment for the rest of this session.",
"approval.details": "Details",
"approval.hideDetails": "Hide",
"approval.deny": "Deny",
@@ -748,6 +758,12 @@ export const en = {
"history.clearTrash": "Empty trash",
"history.confirmClearTrash": "Confirm empty trash",
"history.clearTrashMenu": "Empty trash…",
"history.clearRecoveryCopies": "Clear copies",
"history.confirmClearRecoveryCopies": "Confirm clear copies",
"history.clearRecoveryCopiesMenu": "Clear recovery copies…",
"history.trashRecoveryCopies": "Trash recovery copies",
"history.confirmTrashRecoveryCopies": "Confirm trash copies",
"history.recoveryCopiesGroup": "Recovery copies",
"history.moveToTrash": "Move to trash",
"history.confirmMoveToTrash": "Confirm move to trash",
"history.restore": "Restore",
@@ -788,14 +804,10 @@ export const en = {
// session recovery
"recovery.branch": "Conflict copy",
"recovery.badge": "Copy",
"recovery.open": "Open conflict copy",
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
"recovery.reason": "Reason: {reason}",
"recovery.digest": "Digest: {digest}",
"recovery.parent": "Parent: {parent}",
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
// project tree
"projectTree.workspaceTitle": "Projects",
@@ -1873,6 +1885,34 @@ export const en = {
"transcript.processedDuration": "Processed {s}s",
"notice.info": "Notice",
"notice.warning": "Warning",
"notice.details": "Details",
"notice.finalReadiness": "Task status needs one more check; asking the assistant to finish or explain what is blocking it.",
"notice.emptyFinal": "No visible answer was produced; asking the assistant to respond again.",
"notice.executorHandoff": "The assistant answered before taking action; asking it to use the required tools.",
"notice.toolBudget": "Tool round limit reached; asking the assistant to summarize progress.",
"notice.loopGuard": "The assistant is stuck retrying a blocked action; asking it to change approach.",
"notice.contextLarge": "Context is getting large; preserving cache until cleanup is needed.",
"notice.contextCleanupSkipped": "Context cleanup skipped for now.",
"notice.contextCleanupPaused": "Automatic context cleanup paused because the context window is too small.",
"notice.compactionNoSummary": "Context was compacted without a generated summary.",
"notice.autoPlanEnabled": "Planning mode enabled for this multi-step task.",
"notice.autoPlanRequested": "Plan detection requested a plan.",
"notice.autoPlanFallback": "Plan detection was uncertain; using the fallback planner heuristic.",
"notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.",
"notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.",
"notice.autoresearchStatusFailed": "AutoResearch status update failed.",
"notice.autoresearchBlocked": "AutoResearch task marked blocked.",
"notice.backgroundJobFailed": "Background {kind} needs attention.",
"notice.jobArtifactMigrationFailed": "Job artifact migration failed.",
"notice.jobTeardownTimeout": "Background job teardown timed out.",
"notice.planModeToolSettingsIgnored": "Some plan-mode tool settings were ignored.",
"notice.planModeCommandSettingsIgnored": "Some plan-mode command settings were ignored.",
"notice.configMigrationIncomplete": "Config migration did not complete.",
"notice.modelMissingApiKey": "Selected model is missing its API key.",
"notice.mcpServerFailed": "An MCP server failed to start.",
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
@@ -1920,8 +1960,12 @@ export const en = {
// tool card summaries
"tool.stepOne": "{n} step",
"tool.stepOther": "{n} steps",
"tool.error": "error",
"tool.errorReceiptMismatch": "verification command has no matching successful receipt",
"tool.truncated": "output truncated",
"tool.showAllLines": "show all {n} lines",
"tool.showErrorDetails": "show error details",
"tool.hideErrorDetails": "hide error details",
"tool.readCount": "Read {n} files",
"tool.searchCount": "Search {n} files",
"tool.otherReadCount": "{n} read calls",
+53 -9
View File
@@ -350,7 +350,10 @@ export const zhTW: Record<DictKey, string> = {
"composer.send": "傳送(Enter)",
"composer.queueGuidance": "加入引導佇列(Enter)",
"composer.steer": "追加到目前任務(Enter)",
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
"composer.steerPlaceholder": "正在執行——輸入補充指示,Enter 加入佇列",
"composer.runWaitingApproval": "等待你核准 — {tool}",
"composer.runWaitingAsk": "等待你回答",
"composer.runAnnounceRunning": "正在執行",
"composer.guidanceQueue": "待處理引導",
"composer.guidanceCount": "待處理引導 {n}",
"composer.guidanceRemaining": "還有 {n} 條",
@@ -520,6 +523,13 @@ export const zhTW: Record<DictKey, string> = {
"approval.sendRevision": "傳送修改意見",
"approval.exitPlan": "退出計畫",
"approval.toolPending": "允許工具呼叫",
"approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。",
"approval.toolLabelExitPlan": "計畫確認",
"approval.allowOnceDesc": "僅允許本次呼叫,下次仍會詢問。",
"approval.allowRuleSessionDesc": "本會話內符合的呼叫不再詢問,重啟後失效。",
"approval.allowRulePersistentDesc": "儲存為持久允許規則,之後的會話也不再詢問。",
"approval.denyDesc": "拒絕本次呼叫,模型會收到拒絕結果並繼續。",
"approval.allowSandboxEscapeSessionDesc": "本會話後續命令改在真實環境執行,跳過沙箱。",
"approval.details": "詳情",
"approval.hideDetails": "收起",
"approval.deny": "拒絕",
@@ -586,6 +596,12 @@ export const zhTW: Record<DictKey, string> = {
"history.clearTrash": "清空回收站",
"history.confirmClearTrash": "確認清空回收站",
"history.clearTrashMenu": "清空回收站…",
"history.clearRecoveryCopies": "清理副本",
"history.confirmClearRecoveryCopies": "確認清理副本",
"history.clearRecoveryCopiesMenu": "清理恢復副本…",
"history.trashRecoveryCopies": "副本移入回收站",
"history.confirmTrashRecoveryCopies": "確認移入回收站",
"history.recoveryCopiesGroup": "恢復副本",
"history.moveToTrash": "移到回收站",
"history.confirmMoveToTrash": "確認移入回收站",
"history.restore": "還原",
@@ -626,14 +642,10 @@ export const zhTW: Record<DictKey, string> = {
// 會話恢復
"recovery.branch": "衝突副本",
"recovery.badge": "副本",
"recovery.open": "開啟衝突副本",
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父會話:{parent}",
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
// 專案樹
"projectTree.workspaceTitle": "專案",
@@ -1209,6 +1221,34 @@ export const zhTW: Record<DictKey, string> = {
"transcript.processedDuration": "已處理 {s}s",
"notice.info": "提示",
"notice.warning": "警告",
"notice.details": "詳情",
"notice.finalReadiness": "任務狀態還需要確認,已要求助手完成或說明阻塞原因。",
"notice.emptyFinal": "沒有產生可見回覆,已要求助手重新作答。",
"notice.executorHandoff": "助手尚未執行必要操作,已要求它使用相應工具。",
"notice.toolBudget": "工具呼叫輪數已達上限,已要求助手總結目前進度。",
"notice.loopGuard": "助手卡在重複受阻操作上,已要求它換一種處理方式。",
"notice.contextLarge": "上下文正在變大,暫時保留快取,等需要時再清理。",
"notice.contextCleanupSkipped": "本次暫未清理上下文。",
"notice.contextCleanupPaused": "自動上下文清理已暫停:目前上下文視窗太小。",
"notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。",
"notice.autoPlanEnabled": "已為這個多步驟任務開啟規劃模式。",
"notice.autoPlanRequested": "任務判斷建議先制定計畫。",
"notice.autoPlanFallback": "規劃判斷不確定,已使用備用規則。",
"notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。",
"notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。",
"notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。",
"notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。",
"notice.backgroundJobFailed": "背景 {kind} 需要處理。",
"notice.jobArtifactMigrationFailed": "任務產物遷移失敗。",
"notice.jobTeardownTimeout": "背景任務清理逾時。",
"notice.planModeToolSettingsIgnored": "部分規劃模式工具設定已被忽略。",
"notice.planModeCommandSettingsIgnored": "部分規劃模式指令設定已被忽略。",
"notice.configMigrationIncomplete": "設定遷移未完成。",
"notice.modelMissingApiKey": "目前模型缺少 API key。",
"notice.mcpServerFailed": "一個 MCP 服務啟動失敗。",
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
"questionNav.label": "問題導航",
"questionNav.progress": "問題 {current} / {total}",
"questionNav.jump": "跳轉到問題 {n}",
@@ -1256,8 +1296,12 @@ export const zhTW: Record<DictKey, string> = {
// 工具卡片摘要
"tool.stepOne": "{n} 步",
"tool.stepOther": "{n} 步",
"tool.error": "錯誤",
"tool.errorReceiptMismatch": "證據命令沒有匹配的成功執行記錄",
"tool.truncated": "輸出已截斷",
"tool.showAllLines": "顯示全部 {n} 行",
"tool.showErrorDetails": "顯示錯誤詳情",
"tool.hideErrorDetails": "隱藏錯誤詳情",
"tool.lineOne": "{n} 行",
"tool.lineOther": "{n} 行",
"tool.matchOne": "{n} 處匹配",
+53 -9
View File
@@ -472,7 +472,10 @@ export const zh: Record<DictKey, string> = {
"composer.send": "发送(Enter)",
"composer.queueGuidance": "加入引导队列(Enter)",
"composer.steer": "追加到当前任务(Enter)",
"composer.steerPlaceholder": "输入要加入队列的引导…",
"composer.steerPlaceholder": "正在运行——输入补充指示,回车加入队列",
"composer.runWaitingApproval": "等待你批准 — {tool}",
"composer.runWaitingAsk": "等待你回答",
"composer.runAnnounceRunning": "正在运行",
"composer.guidanceQueue": "待处理引导",
"composer.guidanceCount": "待处理引导 {n}",
"composer.guidanceRemaining": "还有 {n} 条",
@@ -674,6 +677,13 @@ export const zh: Record<DictKey, string> = {
"approval.sendRevision": "发送修改意见",
"approval.exitPlan": "退出计划",
"approval.toolPending": "允许工具调用",
"approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。",
"approval.toolLabelExitPlan": "计划确认",
"approval.allowOnceDesc": "仅允许本次调用,下次仍会询问。",
"approval.allowRuleSessionDesc": "本会话内匹配的调用不再询问,重启后失效。",
"approval.allowRulePersistentDesc": "保存为持久允许规则,之后的会话也不再询问。",
"approval.denyDesc": "拒绝本次调用,模型会收到拒绝结果并继续。",
"approval.allowSandboxEscapeSessionDesc": "本会话后续命令改在真实环境运行,跳过沙箱。",
"approval.details": "详情",
"approval.hideDetails": "收起",
"approval.deny": "拒绝",
@@ -749,6 +759,12 @@ export const zh: Record<DictKey, string> = {
"history.clearTrash": "清空回收站",
"history.confirmClearTrash": "确认清空回收站",
"history.clearTrashMenu": "清空回收站…",
"history.clearRecoveryCopies": "清理副本",
"history.confirmClearRecoveryCopies": "确认清理副本",
"history.clearRecoveryCopiesMenu": "清理恢复副本…",
"history.trashRecoveryCopies": "副本移入回收站",
"history.confirmTrashRecoveryCopies": "确认移入回收站",
"history.recoveryCopiesGroup": "恢复副本",
"history.moveToTrash": "移到回收站",
"history.confirmMoveToTrash": "确认移入回收站",
"history.restore": "恢复",
@@ -789,14 +805,10 @@ export const zh: Record<DictKey, string> = {
// 会话恢复
"recovery.branch": "冲突副本",
"recovery.badge": "副本",
"recovery.open": "打开冲突副本",
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父会话:{parent}",
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
// 项目树
"projectTree.workspaceTitle": "项目",
@@ -1875,6 +1887,34 @@ export const zh: Record<DictKey, string> = {
"transcript.processedDuration": "已处理 {s}s",
"notice.info": "提示",
"notice.warning": "警告",
"notice.details": "详情",
"notice.finalReadiness": "任务状态还需要确认,已要求助手完成或说明阻塞原因。",
"notice.emptyFinal": "没有生成可见回复,已要求助手重新作答。",
"notice.executorHandoff": "助手尚未执行必要操作,已要求它使用相应工具。",
"notice.toolBudget": "工具调用轮数已达上限,已要求助手总结当前进展。",
"notice.loopGuard": "助手卡在重复受阻操作上,已要求它换一种处理方式。",
"notice.contextLarge": "上下文正在变大,暂时保留缓存,等需要时再清理。",
"notice.contextCleanupSkipped": "本次暂未清理上下文。",
"notice.contextCleanupPaused": "自动上下文清理已暂停:当前上下文窗口太小。",
"notice.compactionNoSummary": "上下文已压缩,但未生成摘要。",
"notice.autoPlanEnabled": "已为这个多步骤任务开启规划模式。",
"notice.autoPlanRequested": "任务判断建议先制定计划。",
"notice.autoPlanFallback": "规划判断不确定,已使用备用规则。",
"notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。",
"notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。",
"notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。",
"notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。",
"notice.backgroundJobFailed": "后台 {kind} 需要处理。",
"notice.jobArtifactMigrationFailed": "任务产物迁移失败。",
"notice.jobTeardownTimeout": "后台任务清理超时。",
"notice.planModeToolSettingsIgnored": "部分规划模式工具设置已被忽略。",
"notice.planModeCommandSettingsIgnored": "部分规划模式命令设置已被忽略。",
"notice.configMigrationIncomplete": "配置迁移未完成。",
"notice.modelMissingApiKey": "当前模型缺少 API key。",
"notice.mcpServerFailed": "一个 MCP 服务启动失败。",
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
@@ -1922,8 +1962,12 @@ export const zh: Record<DictKey, string> = {
// 工具卡片摘要
"tool.stepOne": "{n} 步",
"tool.stepOther": "{n} 步",
"tool.error": "错误",
"tool.errorReceiptMismatch": "证据命令没有匹配的成功执行记录",
"tool.truncated": "输出已截断",
"tool.showAllLines": "显示全部 {n} 行",
"tool.showErrorDetails": "显示错误详情",
"tool.hideErrorDetails": "隐藏错误详情",
"tool.readCount": "已读 {n} 个文件",
"tool.searchCount": "搜索 {n} 个文件",
"tool.otherReadCount": "{n} 个只读调用",
+213 -173
View File
@@ -2665,27 +2665,6 @@ a[href] {
color: var(--ok);
border-bottom: 1px solid var(--border-soft);
}
.banner--recovery {
display: flex;
align-items: center;
gap: 8px;
background: color-mix(in srgb, var(--warn) 9%, var(--bg-elev));
color: color-mix(in srgb, var(--warn) 72%, var(--fg));
border-bottom: 1px solid color-mix(in srgb, var(--warn) 22%, var(--border-soft));
}
.banner__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 5px;
background: color-mix(in srgb, var(--warn) 13%, transparent);
color: color-mix(in srgb, var(--warn) 84%, var(--fg));
font-size: 11px;
font-weight: 650;
line-height: 1;
}
.banner--update {
display: flex;
align-items: center;
@@ -3310,6 +3289,20 @@ a[href] {
min-width: 0;
word-break: break-word;
}
.notice-line__details {
margin-top: 4px;
color: var(--fg-faint);
font-size: 12px;
line-height: 1.45;
}
.notice-line__details summary {
cursor: pointer;
user-select: none;
}
.notice-line__details div {
margin-top: 4px;
white-space: pre-wrap;
}
/* ── ReadOnly batch (parent fold for read/search tools) ────────────── */
.readonly-batch {
@@ -5283,6 +5276,46 @@ body > .mermaid-diagram--fullscreen {
font-size: 12px;
white-space: pre-wrap;
}
.tool__err--compact {
display: grid;
gap: 6px;
margin: 0 0 6px;
overflow-wrap: anywhere;
}
.tool__err-summary {
min-width: 0;
}
.tool__err-toggle {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
opacity: 0.82;
}
.tool__err-toggle:hover {
opacity: 1;
}
.tool__err-toggle-icon {
flex: 0 0 auto;
transition: transform 120ms ease;
}
.tool__err-toggle-icon--open {
transform: rotate(90deg);
}
.tool__err-details {
max-height: 240px;
overflow: auto;
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--err) 18%, transparent);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
font-size: 10px;
text-transform: uppercase;
@@ -5431,13 +5464,59 @@ body > .mermaid-diagram--fullscreen {
transition: border-color 0.12s;
}
.composer-card--resized {
height: var(--composer-height);
/* --composer-height is always the user's logical height; the run strip's
reservation lives in this calc so the live resize drag (which writes the
raw logical height) and the React render path can never disagree. */
height: calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px));
display: flex;
flex-direction: column;
}
.composer-card:focus-within {
border-color: var(--fg-faint);
}
/* Run strip: an in-flow bar at the top of the card. For user-resized cards
(fixed --composer-height), the JS adds the strip's reserved height so the
meta row stays fully visible. For auto-height cards the card grows
naturally. */
.composer-run-strip {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
padding: 4px 14px 0;
color: var(--accent);
font-family: var(--sans);
font-size: 12px;
}
.composer-card--waiting .composer-run-strip {
/* inherited from the card border tint; no extra rule needed */
}
.composer-run-strip__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.2s ease-in-out infinite;
}
.composer-run-strip__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.composer-run-strip--waiting {
color: color-mix(in srgb, var(--warn) 74%, var(--fg));
}
.composer-run-strip--waiting .composer-run-strip__dot {
animation: none;
}
/* Attention handoff: while a prompt card is waiting on the user, the card
drops the accent running tint and takes a calm warn edge instead. */
.composer-card--waiting {
border-color: color-mix(in srgb, var(--warn) 30%, var(--border));
}
.composer-resize-handle {
--wails-draggable: no-drag;
appearance: none;
@@ -5691,6 +5770,22 @@ body > .mermaid-diagram--fullscreen {
cursor: default;
transform: none;
}
.composer__btn--stop {
margin-right: 6px;
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
color: var(--err);
}
.composer__btn--stop:hover,
.composer__btn--stop:focus-visible {
color: var(--fg);
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
}
.composer__btn--stop:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.composer-meta {
display: flex;
align-items: center;
@@ -7208,6 +7303,16 @@ body > .mermaid-diagram--fullscreen {
margin-top: 8px;
flex-wrap: wrap;
}
.approval-mode-hint {
margin: 0;
padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 30%, var(--border-soft));
border-radius: 8px;
background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-soft));
color: color-mix(in srgb, var(--warning, #f59e0b) 62%, var(--fg));
font-size: 12px;
line-height: 1.45;
}
.approval-reason {
margin: 0;
padding: 8px 10px;
@@ -7389,14 +7494,14 @@ body > .mermaid-diagram--fullscreen {
}
.prompt-shelf--ask .prompt-shelf__actions {
display: grid;
grid-template-columns: 1fr;
max-height: 154px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
max-height: min(220px, 30vh);
overflow: auto;
padding-right: 2px;
}
.prompt-shelf--ask .prompt-action {
width: 100%;
min-height: 42px;
min-height: 36px;
align-items: center;
border-radius: 8px;
padding: 5px 8px;
@@ -7412,6 +7517,69 @@ body > .mermaid-diagram--fullscreen {
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-shelf--ask .prompt-shelf__quick-actions {
justify-content: flex-end;
gap: 6px;
border-top: 1px solid var(--border-soft);
padding-top: 6px;
margin-top: 2px;
}
.prompt-shelf--ask .prompt-shelf__quick-actions > .prompt-action {
width: auto;
min-height: 28px;
padding: 4px 12px;
border-radius: 999px;
}
.prompt-shelf__footnote {
min-width: 0;
}
.ask-shelf__detail {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 2px;
color: var(--fg-dim);
font-size: 11.5px;
line-height: 1.45;
}
.ask-shelf__detail-label {
flex: 0 0 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
font-weight: 620;
}
.ask-shelf__detail-text {
min-width: 0;
overflow-wrap: anywhere;
}
/* Tool-approval consequence preview: same quick-pick pattern as the ask
detail row — pill buttons stay compact, the row spells out what the
hovered/selected action will do. */
.approval-consequence {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 2px;
color: var(--fg-dim);
font-size: 11.5px;
line-height: 1.45;
}
.approval-consequence__label {
flex: 0 0 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
font-weight: 620;
}
.approval-consequence__text {
min-width: 0;
overflow-wrap: anywhere;
}
.prompt-shelf--ask .ask-shelf__custom-row {
margin-top: 0;
}
@@ -17291,21 +17459,6 @@ body > .mermaid-diagram--fullscreen {
padding-left: 18px;
}
/* ── composer run status ──────────────────────────────────────────────────── */
.composer-toolbar {
--wails-draggable: no-drag;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: var(--maxw);
margin: 0 auto 8px;
}
.composer-toolbar--status-only {
justify-content: flex-end;
}
.composer-guidance-shelf {
--wails-draggable: no-drag;
display: grid;
@@ -17462,79 +17615,6 @@ body > .mermaid-diagram--fullscreen {
}
}
.composer-runstatus {
--wails-draggable: no-drag;
flex: 0 1 auto;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 34px;
padding: 0 5px 0 11px;
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
color: var(--accent);
font-family: var(--sans);
font-size: 12px;
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
}
.composer-runstatus__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.2s ease-in-out infinite;
}
.composer-runstatus__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.composer-runstatus__stop {
--wails-draggable: no-drag;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 26px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
color: var(--err);
font: inherit;
font-weight: 620;
}
.composer-runstatus__stop:hover,
.composer-runstatus__stop:focus-visible {
color: var(--fg);
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
}
.composer-runstatus__stop:disabled {
cursor: not-allowed;
opacity: 0.48;
color: var(--fg-faint);
background: var(--button-bg);
border-color: var(--border-soft);
}
.composer-runstatus__stop:disabled:hover,
.composer-runstatus__stop:disabled:focus-visible {
color: var(--fg-faint);
background: var(--button-bg);
border-color: var(--border-soft);
}
.composer-context {
display: flex;
flex-wrap: wrap;
@@ -22767,10 +22847,6 @@ body > .mermaid-diagram--fullscreen {
padding: 5px 10px 10px 30px;
}
.composer-toolbar {
margin-bottom: 8px;
}
.composer-modebar {
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent);
@@ -26845,41 +26921,29 @@ body > .mermaid-diagram--fullscreen {
margin: 0 auto;
}
.app--creation .composer-toolbar,
:root[data-theme-style] .app--creation .composer-toolbar {
max-width: 880px;
margin: 0 auto 8px;
}
.app--creation .composer-toolbar--status-only,
:root[data-theme-style] .app--creation .composer-toolbar--status-only {
justify-content: center;
}
.app--creation .composer-runstatus,
:root[data-theme-style] .app--creation .composer-runstatus {
max-width: min(100%, 340px);
height: 28px;
gap: 7px;
padding: 0 4px 0 10px;
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elev) 24%, transparent);
.app--creation .composer-run-strip,
:root[data-theme-style] .app--creation .composer-run-strip {
color: color-mix(in srgb, var(--accent) 88%, var(--fg-muted));
font-size: 11.5px;
box-shadow: none;
}
.app--creation .composer-runstatus__dot,
:root[data-theme-style] .app--creation .composer-runstatus__dot {
.app--creation .composer-run-strip__dot,
:root[data-theme-style] .app--creation .composer-run-strip__dot {
width: 5px;
height: 5px;
background: var(--accent);
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 48%, transparent);
animation: creation-runstatus-dot-breathe 1.65s ease-in-out infinite;
animation: creation-run-strip-dot-breathe 1.65s ease-in-out infinite;
}
@keyframes creation-runstatus-dot-breathe {
.app--creation .composer-run-strip--waiting .composer-run-strip__dot,
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__dot {
animation: none;
background: currentColor;
box-shadow: none;
}
@keyframes creation-run-strip-dot-breathe {
0%, 100% {
opacity: 0.42;
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
@@ -26891,46 +26955,22 @@ body > .mermaid-diagram--fullscreen {
}
@media (prefers-reduced-motion: reduce) {
.app--creation .composer-runstatus__dot,
:root[data-theme-style] .app--creation .composer-runstatus__dot {
.app--creation .composer-run-strip__dot,
:root[data-theme-style] .app--creation .composer-run-strip__dot {
animation: none;
opacity: 0.78;
}
}
.app--creation .composer-runstatus__text,
:root[data-theme-style] .app--creation .composer-runstatus__text {
.app--creation .composer-run-strip__text,
:root[data-theme-style] .app--creation .composer-run-strip__text {
color: color-mix(in srgb, var(--fg-muted) 72%, var(--accent));
font-weight: 540;
}
.app--creation .composer-runstatus__stop,
:root[data-theme-style] .app--creation .composer-runstatus__stop {
height: 22px;
gap: 4px;
padding: 0 8px;
border-color: color-mix(in srgb, var(--err) 24%, transparent);
border-radius: 999px;
background: transparent;
color: color-mix(in srgb, var(--err) 76%, var(--fg-muted));
font-size: 11.5px;
font-weight: 560;
}
.app--creation .composer-runstatus__stop svg,
:root[data-theme-style] .app--creation .composer-runstatus__stop svg {
width: 8px;
height: 8px;
opacity: 0.78;
}
.app--creation .composer-runstatus__stop:hover,
.app--creation .composer-runstatus__stop:focus-visible,
:root[data-theme-style] .app--creation .composer-runstatus__stop:hover,
:root[data-theme-style] .app--creation .composer-runstatus__stop:focus-visible {
border-color: color-mix(in srgb, var(--err) 42%, transparent);
background: color-mix(in srgb, var(--err) 9%, transparent);
color: color-mix(in srgb, var(--err) 90%, var(--fg));
.app--creation .composer-run-strip--waiting .composer-run-strip__text,
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__text {
color: color-mix(in srgb, var(--warn) 64%, var(--fg-muted));
}
.app--creation .composer-card,
+46 -4
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)
}
}
+1
View File
@@ -69,6 +69,7 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
if cmd == "" {
continue
}
cmd = hook.NormalizeCommand(cmd)
settings.Hooks[event] = append(settings.Hooks[event], hook.HookConfig{
Match: strings.TrimSpace(h.Match),
Command: cmd,
+7
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
@@ -23,6 +24,12 @@ func TestMain(m *testing.M) {
os.Setenv("REASONIX_STATE_HOME", dir+"/state")
os.Setenv("REASONIX_CACHE_HOME", dir+"/cache")
os.Setenv("AppData", dir)
// Neutralize the Wails runtime-event bridge for the whole test binary:
// outside a running Wails app, runtime.EventsEmit log.Fatals on the plain
// contexts tests use, killing the process from any emitting code path.
// Tests that assert on runtime events install their own capture through
// the per-instance runtimeEvents.emit hook, which takes precedence.
runtimeEventsEmitFallback = func(context.Context, string, ...interface{}) {}
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
+66 -2
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"hash/fnv"
"path/filepath"
"strings"
"time"
@@ -116,6 +117,9 @@ func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
view.Memories = suggestMemories(set, sessions)
// Stable Memory v5 execution learnings join the same candidate list and
// the same explicit-confirmation flow as history-derived suggestions.
view.Memories = append(view.Memories, suggestCompilerMemories(workspaceRoot, set, view.Memories)...)
view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
return view
}
@@ -137,7 +141,7 @@ func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (s
if desc == "" || body == "" {
return "", fmt.Errorf("memory suggestion requires description and body")
}
name := suggestionName(in.Name, desc, "memory-candidate")
name := acceptedSuggestionName(in.Name, desc)
return ctrl.SaveMemory(memory.Memory{
Name: name,
Title: oneLine(in.Title),
@@ -229,7 +233,7 @@ func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySugg
continue
}
seen[key] = true
name := suggestionName("", statement, fmt.Sprintf("memory-candidate-%d", len(out)+1))
name := stableSuggestionName(statement, "memory-candidate")
title := suggestionTitle(statement, "Memory candidate")
typ := inferMemoryType(statement)
out = append(out, MemorySuggestion{
@@ -504,6 +508,66 @@ func suggestionName(given, source, fallback string) string {
return "candidate"
}
// acceptedSuggestionName preserves the candidate name generated at suggestion
// time. Re-running asciiSlug here would truncate back to 56 chars and strip
// the uniqueness hash suffix, re-colliding long common-prefix candidates at
// save time even though their generated Name/ID differed. A well-formed slug
// is kept verbatim (memory.Store.Save's own slug pass cleans but never
// truncates); anything else falls back to deriving from the description as
// before.
func acceptedSuggestionName(given, desc string) string {
if isWellFormedSlug(given) {
return given
}
return suggestionName("", desc, "memory-candidate")
}
// isWellFormedSlug reports whether s already matches asciiSlug's output shape
// (lowercase ASCII letters/digits separated by single dashes), possibly with a
// hash suffix beyond asciiSlug's 56-char cap.
func isWellFormedSlug(s string) bool {
if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
prevDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
prevDash = false
case r == '-':
if prevDash {
return false
}
prevDash = true
default:
return false
}
}
return true
}
// stableSuggestionName returns a slug that is unique per source text and stable
// across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
// 56 chars, so two CJK-only statements (or long English statements sharing a
// prefix) can collide — colliding Names make Store.Save overwrite the earlier
// memory, and colliding IDs cross-wire the frontend's accepted-state map.
//
// When the ASCII slug is short enough that truncation cannot have caused a
// collision, it is returned as-is for backward compatibility with old-version
// candidate names. The hash suffix is only appended when the slug fell back to
// the fallback (non-ASCII source) or when the slug hit the 56-char truncation
// boundary.
func stableSuggestionName(source, fallback string) string {
slug := asciiSlug(source)
if slug != "" && len(slug) < 56 {
return slug
}
base := suggestionName("", source, fallback)
h := fnv.New32a()
_, _ = h.Write([]byte(source))
return fmt.Sprintf("%s-%08x", base, h.Sum32())
}
func asciiSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"fmt"
"hash/fnv"
"reasonix/internal/config"
"reasonix/internal/memory"
"reasonix/internal/memorycompiler"
)
const (
// compilerSuggestionLimit bounds Memory v5 candidates separately from the
// history-derived ones so a busy history cannot crowd them out.
compilerSuggestionLimit = 3
// compilerSuggestionMinCount requires a pattern to recur across at least
// this many separate turns before it is stable enough to suggest.
compilerSuggestionMinCount = 2
)
// suggestCompilerMemories converts stable Memory v5 execution learnings into
// memory candidates for the settings Memory page. Read-only: candidates are
// only persisted through the regular AcceptMemorySuggestion confirmation flow.
func suggestCompilerMemories(workspaceRoot string, set *memory.Set, already []MemorySuggestion) []MemorySuggestion {
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
if rt == nil || set == nil {
return nil
}
existing := existingMemoryText(set)
seen := map[string]bool{}
for _, s := range already {
seen[normalizeSuggestionKey(s.Description)] = true
}
var out []MemorySuggestion
for _, p := range rt.StableNoisePatterns(compilerSuggestionMinCount, compilerSuggestionLimit*2) {
statement := compilerPatternStatement(p.Pattern)
key := normalizeSuggestionKey(statement)
if key == "" || seen[key] || existingCovers(existing, key) {
continue
}
seen[key] = true
name := compilerCandidateName(p.Pattern)
out = append(out, MemorySuggestion{
ID: "memory-" + name,
Name: name,
Title: suggestionTitle(statement, "Memory v5 pattern"),
Description: oneLine(statement),
Type: string(memory.TypeProject),
Body: compilerCandidateBody(statement, p.Count),
Reason: fmt.Sprintf("Memory v5 observed this failure pattern in %d separate turns", p.Count),
Evidence: []string{fmt.Sprintf("memory-v5 execution traces: %s (x%d)", truncateRunes(p.Pattern, 160), p.Count)},
})
if len(out) >= compilerSuggestionLimit {
break
}
}
return out
}
// compilerCandidateName always appends a hash: compiler patterns are
// inherently homogeneous (same tool + similar error prefix) so the ASCII
// slug alone cannot distinguish them even when it is non-empty.
func compilerCandidateName(pattern string) string {
base := suggestionName("", "memory-v5 "+pattern, "memory-v5-pattern")
h := fnv.New32a()
_, _ = h.Write([]byte(pattern))
return fmt.Sprintf("%s-%08x", base, h.Sum32())
}
func compilerPatternStatement(pattern string) string {
pattern = oneLine(pattern)
if pattern == "" {
return ""
}
return "Known repeated failure in this workspace: " + pattern + "."
}
func compilerCandidateBody(statement string, count int) string {
return statement + "\n\n" +
fmt.Sprintf("**Why:** Memory v5 recorded this failure pattern in %d separate execution traces for this workspace.\n", count) +
"**How to apply:** Address the known cause before retrying the same command; drop this memory once the failure stops reproducing.\n"
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"context"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/memory"
"reasonix/internal/memorycompiler"
)
func seedCompilerFailures(t *testing.T, workspaceRoot string, turns int, errText string) {
t.Helper()
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
if rt == nil {
t.Fatal("memory compiler dir did not resolve under isolated dirs")
}
for i := 0; i < turns; i++ {
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
turn.RecordToolResults([]memorycompiler.ToolRecord{
{Name: "bash", Error: errText},
{Name: "bash", Error: errText},
})
turn.Finish(nil)
}
}
func TestMemorySuggestionsIncludeCompilerCandidates(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
var candidate MemorySuggestion
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
candidate = item
break
}
}
if candidate.ID == "" {
t.Fatalf("memories = %+v, want a memory-v5 candidate", view.Memories)
}
if candidate.Type != string(memory.TypeProject) {
t.Fatalf("candidate type = %q, want project", candidate.Type)
}
if !strings.Contains(candidate.Description, "cannot find module") {
t.Fatalf("candidate description missing pattern: %q", candidate.Description)
}
if !strings.Contains(candidate.Reason, "Memory v5") {
t.Fatalf("candidate reason missing provenance: %q", candidate.Reason)
}
path, err := app.AcceptMemorySuggestion(candidate)
if err != nil {
t.Fatalf("AcceptMemorySuggestion: %v", err)
}
if path == "" {
t.Fatal("AcceptMemorySuggestion returned empty path")
}
saved := store.List()
if len(saved) != 1 || !strings.Contains(saved[0].Body, "cannot find module") {
t.Fatalf("saved memories = %+v, want compiler candidate body", saved)
}
// Once accepted, the same pattern is covered by an existing memory and
// must not be suggested again.
again := app.MemorySuggestions()
for _, item := range again.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
t.Fatalf("accepted pattern suggested again: %+v", item)
}
}
}
func TestCompilerCandidatesRequireStablePattern(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
// A pattern seen in only one turn is not stable enough to suggest.
seedCompilerFailures(t, cwd, 1, "cannot find module providing package foo")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
t.Fatalf("unstable pattern was suggested: %+v", item)
}
}
}
// TestCompilerCandidateNamesUniqueForSimilarPatterns covers the review
// finding: asciiSlug drops non-ASCII runes and truncates to 56 chars, so
// patterns differing only in CJK text (or sharing a long English prefix) used
// to collide on Name/ID — colliding IDs cross-wire the frontend's accepted
// state and colliding Names make Store.Save overwrite the earlier memory.
func TestCompilerCandidateNamesUniqueForSimilarPatterns(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
// Same tool, errors differ only in CJK text: identical after asciiSlug.
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块甲")
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块乙")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
var candidates []MemorySuggestion
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
candidates = append(candidates, item)
}
}
if len(candidates) != 2 {
t.Fatalf("candidates = %+v, want both CJK-only-diff patterns", candidates)
}
if candidates[0].Name == candidates[1].Name || candidates[0].ID == candidates[1].ID {
t.Fatalf("similar patterns collided on Name/ID: %q vs %q", candidates[0].Name, candidates[1].Name)
}
// Accepting both must persist two distinct memories, not overwrite one.
for _, c := range candidates {
if _, err := app.AcceptMemorySuggestion(c); err != nil {
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
}
}
saved := store.List()
if len(saved) != 2 {
t.Fatalf("saved memories = %d (%+v), want 2 distinct files", len(saved), saved)
}
}
// TestCompilerCandidateNamesStableAcrossRefreshes: the hash suffix must be
// derived from the pattern, not from ordering or randomness, so a refresh
// keeps the same ID and the frontend's accepted-state map stays valid.
func TestCompilerCandidateNamesStableAcrossRefreshes(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
first := app.MemorySuggestions()
second := app.MemorySuggestions()
firstIDs := compilerCandidateIDs(first.Memories)
secondIDs := compilerCandidateIDs(second.Memories)
if len(firstIDs) == 0 || strings.Join(firstIDs, ",") != strings.Join(secondIDs, ",") {
t.Fatalf("candidate IDs changed across refreshes: %v vs %v", firstIDs, secondIDs)
}
}
func compilerCandidateIDs(memories []MemorySuggestion) []string {
var out []string
for _, item := range memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
out = append(out, item.ID)
}
}
return out
}
// TestCompilerCandidateLongPrefixNamesPersistDistinctly reproduces the review
// finding: two patterns sharing a >56-char ASCII prefix get distinct generated
// Name/ID (hash suffix), but the accept path used to re-run asciiSlug, which
// truncates to 56 chars and strips the hash — so both saved to one file.
func TestCompilerCandidateLongPrefixNamesPersistDistinctly(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
prefix := "cannot find module providing package example dot com slash very long prefix "
seedCompilerFailures(t, cwd, 2, prefix+"alpha")
seedCompilerFailures(t, cwd, 2, prefix+"beta")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
var candidates []MemorySuggestion
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
candidates = append(candidates, item)
}
}
if len(candidates) != 2 {
t.Fatalf("candidates = %+v, want both long-prefix patterns", candidates)
}
for _, c := range candidates {
if _, err := app.AcceptMemorySuggestion(c); err != nil {
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
}
}
saved := store.List()
if len(saved) != 2 {
t.Fatalf("saved memories = %d, want 2 distinct files (accept path truncated the hash suffix)", len(saved))
}
if saved[0].Name == saved[1].Name {
t.Fatalf("saved names collided: %q", saved[0].Name)
}
}
+126
View File
@@ -210,3 +210,129 @@ func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider
t.Fatalf("save session %s: %v", name, err)
}
}
// TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
// asciiSlug is short (<56 chars) must produce the same Name as old code
// (plain slug, no hash suffix), so an already-accepted memory under the old
// Name is not duplicated after upgrade.
func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
writeSuggestionSession(t, sessionDir, "en.jsonl",
provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
)
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
if len(view.Memories) == 0 {
t.Fatalf("no candidates")
}
// Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
oldName := asciiSlug("Always prefer English for code comments.")
if view.Memories[0].Name != oldName {
t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
}
}
// TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
// differ in content but produce the same empty asciiSlug must still get
// distinct Name/ID. Without the hash suffix they would both fall back to
// "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
// wouldn't survive refresh, and Store.Save overwrites by name.
func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
// Two pure-CJK "always" statements that pass extractMemoryStatement but
// share the exact same empty asciiSlug.
writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
)
writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
)
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
if len(view.Memories) < 2 {
t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
}
names := map[string]bool{}
ids := map[string]bool{}
for _, m := range view.Memories {
if names[m.Name] {
t.Fatalf("duplicate Name %q among history candidates", m.Name)
}
if ids[m.ID] {
t.Fatalf("duplicate ID %q among history candidates", m.ID)
}
names[m.Name] = true
ids[m.ID] = true
}
// Accept both → two distinct persisted memories.
for _, c := range view.Memories {
if _, err := app.AcceptMemorySuggestion(c); err != nil {
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
}
}
saved := store.List()
if len(saved) != len(view.Memories) {
t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
}
}
// TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
// be derived from the statement, not from iteration order or random state, so
// a refresh keeps the same ID and the frontend's accepted-state map stays valid.
func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
writeSuggestionSession(t, sessionDir, "pref.jsonl",
provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
)
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
first := app.MemorySuggestions()
second := app.MemorySuggestions()
if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
}
for i := range first.Memories {
if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
i, first.Memories[i].Name, first.Memories[i].ID,
second.Memories[i].Name, second.Memories[i].ID)
}
}
}
+1 -1
View File
@@ -309,7 +309,7 @@ func (m *metricsAggregator) observe(e event.Event) {
case event.MemoryCompilerStatsEvent:
m.observeMemoryCompilerStats(e.MemoryCompiler)
case event.Notice:
if strings.HasPrefix(e.Text, "empty final answer blocked") {
if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
m.inc("empty_final", "total")
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ func TestObserveClassifiesEvents(t *testing.T) {
StrategyCount: 3,
LearningCount: 6,
}},
{Kind: event.Notice, Text: "empty final answer blocked: model returned no visible answer text; retrying"},
{Kind: event.Notice, Text: "No visible answer was produced; asking the assistant to respond again.", Detail: "empty final answer blocked: model returned no visible answer text; retrying"},
{Kind: event.TurnDone, Err: errors.New("deepseek-flash: status 429: rate limited")},
{Kind: event.TurnDone},
}
+3 -2
View File
@@ -86,10 +86,11 @@ func recoveryGCDirs() []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
if dir == "" || seen[dir] {
key := projectRootKey(dir)
if dir == "" || seen[key] {
return
}
seen[dir] = true
seen[key] = true
dirs = append(dirs, dir)
}
add(desktopSessionDir(globalWorkspaceRoot()))
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
// prompt-chime dedupe depends on: every in-place controller replacement must
// announce itself. Model, effort, and token-mode switches rebuild the tab's
// controller WITHOUT an agent:ready — the rebuilt controller restarts its
// approval/ask id counter at "1", so without runtime:rebuilt the frontend's
// id-keyed chime dedupe mutes the first prompt after a switch.
func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old", "new"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "rebuild-events.jsonl")
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
app := NewApp()
app.ctx = context.Background()
// emitReady calls the Wails runtime directly; the ready hook keeps the
// workspace-reconcile path (which SetEffortForTab can take) off the real
// event bridge, which log.Fatals on a plain Background context.
app.readyHook = func() {}
var mu sync.Mutex
var rebuilt []string
// The App-level queue must stay silent: ordering against the tab's agent
// events only holds when the notice rides the tab sink's own queue, so a
// notice showing up here means the routing regressed to the fallback.
app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
if name == "runtime:rebuilt" {
mu.Lock()
rebuilt = append(rebuilt, "VIA-APP-QUEUE")
mu.Unlock()
}
}
sinkEmit := func(_ context.Context, name string, payload ...interface{}) {
if name != "runtime:rebuilt" {
return
}
tabID := ""
if len(payload) > 0 {
tabID, _ = payload[0].(string)
}
mu.Lock()
rebuilt = append(rebuilt, tabID)
mu.Unlock()
}
tab := &WorkspaceTab{
ID: "tab_rebuild_events",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
Ready: true,
model: "old/old-model",
Ctrl: ctrl,
sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
disabledMCP: map[string]ServerView{},
}
tab.sink.runtimeEvents.emit = sinkEmit
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
})
waitCount := func(want int, step string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := len(rebuilt)
mu.Unlock()
if n >= want {
return
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
}
if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
t.Fatalf("SetModelForTab: %v", err)
}
waitCount(1, "model switch")
if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
t.Fatalf("SetEffortForTab: %v", err)
}
waitCount(2, "effort switch")
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
t.Fatalf("SetTokenModeForTab: %v", err)
}
waitCount(3, "token-mode switch")
mu.Lock()
defer mu.Unlock()
for i, id := range rebuilt {
if id == "VIA-APP-QUEUE" {
t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
}
if id != tab.ID {
t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
}
}
}
+1
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"},
+18
View File
@@ -170,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)
@@ -205,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")
@@ -225,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")
}
@@ -258,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)
}
@@ -493,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)
@@ -538,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)
}
@@ -835,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),
@@ -872,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),
+8 -4
View File
@@ -1563,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
@@ -1601,7 +1605,7 @@ 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.
@@ -2379,7 +2383,7 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
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)
@@ -2498,7 +2502,7 @@ func (a *App) deleteProviderAndRetargetTabs(name string) error {
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)
+24
View File
@@ -1137,6 +1137,30 @@ func TestSaveHooksSettingsPreservesUnknownSettingsKeys(t *testing.T) {
}
}
func TestSaveHooksSettingsNormalizesQuotedNodeEvalHookCommand(t *testing.T) {
isolateDesktopUserDirs(t)
script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
bad := `node -e "\"` + script + `\""`
want := hook.NormalizeCommand(bad)
if want == bad {
t.Fatal("test command did not normalize")
}
app := NewApp()
if err := app.SaveHooksSettings("global", []HookConfigView{{
Event: string(hook.PreToolUse),
Match: "bash",
Command: bad,
}}); err != nil {
t.Fatalf("SaveHooksSettings: %v", err)
}
view := app.HooksSettings("global")
if len(view.Hooks) != 1 || view.Hooks[0].Command != want {
t.Fatalf("HooksSettings = %+v, want normalized command %q", view.Hooks, want)
}
}
func TestProjectHooksSettingsUseActiveWorkspaceRootAndTrust(t *testing.T) {
home := isolateDesktopUserDirs(t)
project := t.TempDir()
+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)
}
}
+297 -112
View File
@@ -39,25 +39,26 @@ import (
// memory, permissions) scoped to a workspace root, so multiple projects and
// topics can be active concurrently without interfering.
type WorkspaceTab struct {
ID string // stable random id
Scope string // "project" | "global"
WorkspaceRoot string // project root dir (empty for global)
SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
TopicID string // topic within the project
TopicTitle string // display title
SessionPath string // exact .jsonl file this tab continues
ReadOnly bool // true for external channel transcripts opened for browsing
Ctrl control.SessionAPI // nil while booting / on error
Label string // model label (for the tab badge)
Ready bool // true once boot.Build completes
StartupErr string // build error, surfaced to the frontend
sessionLease *agent.SessionLease
sessionLeaseMu sync.Mutex
sink *tabEventSink // routes events with this tab's ID
buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
buildGeneration uint64 // identifies the current in-flight build
removed bool // set when the visible tab is pruned/closed before build completes
reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
ID string // stable random id
Scope string // "project" | "global"
WorkspaceRoot string // project root dir (empty for global)
SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
TopicID string // topic within the project
TopicTitle string // display title
SessionPath string // exact .jsonl file this tab continues
ReadOnly bool // true for external channel transcripts opened for browsing
Ctrl control.SessionAPI // nil while booting / on error
Label string // model label (for the tab badge)
Ready bool // true once boot.Build completes
StartupErr string // build error, surfaced to the frontend
StartupErrLeaseHeld bool // true when StartupErr can be retried after a session lease releases
sessionLease *agent.SessionLease
sessionLeaseMu sync.Mutex
sink *tabEventSink // routes events with this tab's ID
buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
buildGeneration uint64 // identifies the current in-flight build
removed bool // set when the visible tab is pruned/closed before build completes
reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
ActivityStatus string // transient project-tree status for the in-flight turn
@@ -479,6 +480,7 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
Label: tab.Label,
Ready: tab.Ready,
StartupErr: tab.StartupErr,
StartupErrLeaseHeld: tab.StartupErrLeaseHeld,
sink: tab.sink,
ActivityStatus: tab.ActivityStatus,
readTelemetry: readTelemetry,
@@ -569,7 +571,7 @@ func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context
target.SharedHostKey = source.SharedHostKey
target.Label = source.Label
target.Ready = true
target.StartupErr = ""
clearTabStartupError(target)
target.ActivityStatus = source.ActivityStatus
target.model = source.model
target.effort = cloneStringPtr(source.effort)
@@ -864,7 +866,7 @@ func (t *WorkspaceTab) recordPlannerDisplayEvent(e event.Event) {
if e.Level == event.LevelWarn {
level = "warn"
}
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "notice", Level: level, Content: e.Text})
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "notice", Level: level, Content: e.Text, Detail: e.Detail})
}
}
}
@@ -1057,6 +1059,13 @@ type runtimeEventEnvelope struct {
// emission. runtime.EventsEmit can block when the single webview event channel
// backs up; callers enqueue in-order work and return without holding the
// agent's event.Sync lock.
// runtimeEventsEmitFallback is the emit used when no per-instance override is
// installed. Production keeps the real Wails bridge; the test binary swaps in
// a no-op via TestMain, because Wails EventsEmit log.Fatals outside a running
// Wails app and would kill the whole test process from any code path that
// emits a runtime event with a plain Background context.
var runtimeEventsEmitFallback runtimeEventEmitFunc = runtime.EventsEmit
type asyncRuntimeEmitter struct {
mu sync.Mutex
emit runtimeEventEmitFunc
@@ -1112,7 +1121,7 @@ func (e *asyncRuntimeEmitter) run() {
}
emit := e.emit
if emit == nil {
emit = runtime.EventsEmit
emit = runtimeEventsEmitFallback
}
e.mu.Unlock()
@@ -1155,6 +1164,35 @@ func isBackgroundJobLifecycleNotice(e event.Event) bool {
strings.Contains(text, " killed: "))
}
// notifyTabRuntimeRebuilt tells the frontend a tab's controller was replaced
// in place (model/effort/token-mode switch, clear-while-running). A rebuilt
// controller restarts its approval/ask id counter at "1", so tab-scoped
// frontend state keyed by prompt id (the attention-chime dedupe) must reset —
// unlike agent:ready, this event carries no reload semantics, so emitting it
// on every swap adds no hydration churn.
//
// Ordering matters: the reset must reach the frontend BEFORE the rebuilt
// controller's first approval/ask event, or the stale key still mutes it. The
// tab's agent events ride the tab sink's own async queue, so the notice goes
// through THAT queue — same lane, FIFO, guaranteed to arrive first. The
// App-level queue is only the fallback when the sink cannot deliver (no sink,
// or its webview context is cleared); it cannot order against sink traffic,
// but an unordered notice still beats none.
func (a *App) notifyTabRuntimeRebuilt(tab *WorkspaceTab) {
if tab == nil {
return
}
a.mu.RLock()
sink := tab.sink
tabID := tab.ID
a.mu.RUnlock()
if sink != nil && sink.context() != nil {
sink.emitRuntimeEvent("runtime:rebuilt", tabID)
return
}
a.emitRuntimeEvent("runtime:rebuilt", tabID)
}
func (a *App) emitReady(ctx context.Context, tabID ...string) {
a.mu.RLock()
hook := a.readyHook
@@ -1513,6 +1551,42 @@ func (a *App) ListTabs() []TabMeta {
return enrichTabMetas(out)
}
// syncTabWorkspaceRootSpellings repoints open project tabs at the registry's
// canonical root spelling after a registry write may have rewritten it
// (addProject and friends adopt the caller's spelling). Tabs, the project
// tree, and persisted tab state then agree on a single string form of each
// root, which the frontend compares exactly. Callers must not hold a.mu.
func (a *App) syncTabWorkspaceRootSpellings() {
projects := loadProjectsFile().Projects
a.mu.Lock()
changed := false
for _, tab := range a.tabs {
if tab == nil || tab.Scope != "project" {
continue
}
i := projectIndexByRoot(projects, tab.WorkspaceRoot)
if i < 0 || tab.WorkspaceRoot == projects[i].Root {
continue
}
tab.WorkspaceRoot = projects[i].Root
changed = true
}
if changed {
a.saveTabsLocked()
}
a.mu.Unlock()
if changed {
a.emitProjectTreeChanged()
}
}
// registerProjectRoot indexes workspaceRoot in the project registry and
// realigns open tabs when the registry adopted a new spelling of the root.
func (a *App) registerProjectRoot(workspaceRoot string) {
_ = addProject(workspaceRoot, "")
a.syncTabWorkspaceRootSpellings()
}
// OpenProjectTab builds a controller scoped to workspaceRoot and opens the
// session selected by the given topic. Topic selection resolves to a concrete
// session path first; the visible tab is then attached to that session runtime.
@@ -1524,7 +1598,7 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
workspaceRoot = abs
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
a.registerProjectRoot(workspaceRoot)
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
return a.openTopicTab("project", workspaceRoot, topicID, sessionPath)
@@ -1541,7 +1615,7 @@ func (a *App) openProjectTabInactive(workspaceRoot, topicID string) (TabMeta, er
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
_ = addProject(workspaceRoot, "")
a.registerProjectRoot(workspaceRoot)
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, false)
@@ -1675,7 +1749,7 @@ func (a *App) OpenTopicSession(scope, workspaceRoot, topicID, sessionPath string
return TabMeta{}, fmt.Errorf("workspaceRoot is required")
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
a.registerProjectRoot(workspaceRoot)
}
_, validPath, err := a.sessionDirForPath(sessionPath)
if err != nil {
@@ -1728,13 +1802,13 @@ func tabMatchesTopicTarget(tab *WorkspaceTab, scope, workspaceRoot, topicID stri
if scope == "global" {
return true
}
return normalizeProjectRoot(tab.WorkspaceRoot) == normalizeProjectRoot(workspaceRoot)
return sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
}
func tabInWorkspace(tab *WorkspaceTab, workspaceRoot string) bool {
return tab != nil &&
tab.Scope == "project" &&
normalizeProjectRoot(tab.WorkspaceRoot) == normalizeProjectRoot(workspaceRoot)
sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
}
// EnsureBlankTab activates the existing blank tab for the target scope, or
@@ -1756,7 +1830,7 @@ func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
workspaceRoot = abs
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
a.registerProjectRoot(workspaceRoot)
} else {
workspaceRoot = ""
globalRoot = globalWorkspaceRoot()
@@ -1911,7 +1985,7 @@ func (a *App) blankTabMatchesTargetLocked(tab *WorkspaceTab, scope, workspaceRoo
if tab == nil || tab.Scope != scope {
return false
}
if scope == "project" && tab.WorkspaceRoot != workspaceRoot {
if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
return false
}
if tab.Ctrl == nil {
@@ -2013,13 +2087,8 @@ func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
var topicIDs []string
if scope == "global" {
topicIDs = orderedTopicIDs(f.GlobalTopics, titles)
} else {
for _, project := range f.Projects {
if project.Root == workspaceRoot {
topicIDs = orderedTopicIDs(project.Topics, titles)
break
}
}
} else if i := projectIndexByRoot(f.Projects, workspaceRoot); i >= 0 {
topicIDs = orderedTopicIDs(f.Projects[i].Topics, titles)
}
if len(topicIDs) == 0 {
return ""
@@ -2039,7 +2108,7 @@ func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
if tab == nil || tab.Scope != scope || strings.TrimSpace(tab.TopicID) == "" {
continue
}
if scope == "project" && tab.WorkspaceRoot != workspaceRoot {
if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
continue
}
openTopics[tab.TopicID] = true
@@ -2604,6 +2673,23 @@ func (a *App) desktopControllerSink(inner event.Sink, cfg config.NotificationsCo
return notify.NewSink(inner, sender, cfg)
}
func setTabStartupError(tab *WorkspaceTab, err error) bool {
if tab == nil {
return false
}
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
tab.StartupErrLeaseHeld = errors.Is(err, agent.ErrSessionLeaseHeld)
return tab.StartupErrLeaseHeld
}
func clearTabStartupError(tab *WorkspaceTab) {
if tab == nil {
return
}
tab.StartupErr = ""
tab.StartupErrLeaseHeld = false
}
func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
defer a.recoverToPending("buildTabController")
keepBuildContext := false
@@ -2640,15 +2726,19 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
_ = config.MigrateLegacyCredentialsForRoot(root)
cfg, err := config.LoadForRoot(root)
if err != nil {
leaseHeld := false
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
return
}
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
tab.releaseSessionLease()
a.mu.Unlock()
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(wailsCtx, tab.ID)
return
}
@@ -2765,13 +2855,14 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
leaseHeld := false
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
a.abandonSupersededBuild(tab, nil, rootKey, "")
return
}
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
hostKey := takeTabSharedHostKey(tab)
tab.releaseSessionLease()
@@ -2779,6 +2870,9 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
if hostKey != "" {
a.releaseSharedHost(hostKey)
}
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(wailsCtx, tab.ID)
return
}
@@ -2850,13 +2944,14 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
}
preLeaseKey := tab.sessionLeaseRuntimeKey()
if err := tab.ensureSessionLease(path); err != nil {
leaseHeld := false
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
return
}
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
leaseHeld = setTabStartupError(tab, err)
tab.Ready = true
hostKey := takeTabSharedHostKey(tab)
// Release only a lease bound to THIS build's session: a failed
@@ -2868,6 +2963,9 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
if hostKey != "" {
a.releaseSharedHost(hostKey)
}
if leaseHeld {
a.scheduleDeferredStartupBuild(tab.ID)
}
a.emitReady(wailsCtx, tab.ID)
return
}
@@ -2929,7 +3027,7 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
tab.Ctrl = ctrl
tab.Label = ctrl.Label()
tab.Ready = true
tab.StartupErr = ""
clearTabStartupError(tab)
keepBuildContext = true
a.mu.Unlock()
a.emitReady(wailsCtx, tab.ID)
@@ -2967,7 +3065,7 @@ func (a *App) reconcileTabWithPinnedSessionMeta(tab *WorkspaceTab) (string, bool
return "", false
}
if tab.Scope == "project" && binding.scope != "project" && normalizeProjectRoot(tab.WorkspaceRoot) != "" {
if root, ok := safeControllerWorkspaceRoot(tab.Ctrl); ok && normalizeProjectRoot(root) == normalizeProjectRoot(tab.WorkspaceRoot) {
if root, ok := safeControllerWorkspaceRoot(tab.Ctrl); ok && sameProjectRoot(root, tab.WorkspaceRoot) {
return "", false
}
}
@@ -3001,7 +3099,7 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding
if workspaceRoot == "" {
return
}
_ = addProject(workspaceRoot, "")
a.registerProjectRoot(workspaceRoot)
} else {
scope = "global"
workspaceRoot = globalTabWorkspaceRoot()
@@ -3023,7 +3121,9 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding
changed := tab.Scope != scope ||
tab.WorkspaceRoot != workspaceRoot ||
canonicalTabSessionPath(tab.SessionPath) != canonicalTabSessionPath(binding.path)
workspaceChanged := tab.Scope != scope || tab.WorkspaceRoot != workspaceRoot
// Spelling-only root updates still persist above, but an equivalent root is
// the same workspace — do not warn the user about a switch.
workspaceChanged := tab.Scope != scope || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
tab.Scope = scope
tab.WorkspaceRoot = workspaceRoot
tab.SessionPath = canonicalTabSessionPath(binding.path)
@@ -3808,10 +3908,11 @@ func desktopMCPMigrationRoots(tabs desktopTabsFile) []string {
var roots []string
add := func(root string) {
root = normalizeProjectRoot(root)
if root == "" || seen[root] {
key := projectRootKey(root)
if root == "" || seen[key] {
return
}
seen[root] = true
seen[key] = true
roots = append(roots, root)
}
if cur := loadWorkspace(); cur != "" {
@@ -3843,16 +3944,17 @@ func recoverLegacyProjectSidebarRoots(tabs desktopTabsFile) (bool, error) {
for _, project := range f.Projects {
root := normalizeProjectRoot(project.Root)
if root != "" {
seen[root] = true
seen[projectRootKey(root)] = true
}
}
add := func(root string) {
root = normalizeProjectRoot(root)
if root == "" || seen[root] || !existingDirectory(root) {
key := projectRootKey(root)
if root == "" || seen[key] || !existingDirectory(root) {
return
}
seen[root] = true
seen[key] = true
f.Projects = append(f.Projects, desktopProject{Root: root})
changed = true
}
@@ -3985,7 +4087,7 @@ func prependTopicsInProjectsFileOpts(workspaceRoot string, topicIDs []string, en
return true, nil
}
for i, p := range f.Projects {
if p.Root != workspaceRoot {
if !sameProjectRoot(p.Root, workspaceRoot) {
continue
}
next := uniqueStrings(append(append([]string(nil), live...), p.Topics...))
@@ -4047,6 +4149,36 @@ func normalizeProjectRoot(root string) string {
return root
}
func sameProjectRoot(a, b string) bool {
return sameDesktopPath(normalizeProjectRoot(a), normalizeProjectRoot(b))
}
func projectIndexByRoot(projects []desktopProject, root string) int {
root = normalizeProjectRoot(root)
if root == "" {
return -1
}
for i, project := range projects {
if sameProjectRoot(project.Root, root) {
return i
}
}
return -1
}
func projectRootInList(roots []string, root string) bool {
root = normalizeProjectRoot(root)
if root == "" {
return false
}
for _, candidate := range roots {
if sameProjectRoot(candidate, root) {
return true
}
}
return false
}
func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
out := desktopProjectFile{
GlobalTitle: strings.TrimSpace(f.GlobalTitle),
@@ -4055,7 +4187,6 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
GlobalPinnedTopics: uniqueStrings(f.GlobalPinnedTopics),
DeletedTopics: uniqueStrings(f.DeletedTopics),
}
index := map[string]int{}
for _, p := range f.Projects {
root := normalizeProjectRoot(p.Root)
if root == "" {
@@ -4066,7 +4197,7 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
p.Color = normalizeProjectColor(p.Color)
p.Topics = uniqueStrings(p.Topics)
p.PinnedTopics = uniqueStrings(p.PinnedTopics)
if i, ok := index[root]; ok {
if i := projectIndexByRoot(out.Projects, root); i >= 0 {
if out.Projects[i].Title == "" && p.Title != "" {
out.Projects[i].Title = p.Title
}
@@ -4077,17 +4208,12 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
out.Projects[i].PinnedTopics = uniqueStrings(append(out.Projects[i].PinnedTopics, p.PinnedTopics...))
continue
}
index[root] = len(out.Projects)
out.Projects = append(out.Projects, p)
}
projectRoots := make(map[string]bool, len(out.Projects))
for _, project := range out.Projects {
projectRoots[project.Root] = true
}
for _, root := range uniqueStrings(f.PinnedProjects) {
root = normalizeProjectRoot(root)
if root != "" && projectRoots[root] && !containsDesktopString(out.PinnedProjects, root) {
out.PinnedProjects = append(out.PinnedProjects, root)
if i := projectIndexByRoot(out.Projects, root); i >= 0 && !projectRootInList(out.PinnedProjects, out.Projects[i].Root) {
out.PinnedProjects = append(out.PinnedProjects, out.Projects[i].Root)
}
}
out.SidebarOrder = normalizeSidebarOrder(f.SidebarOrder, out.Projects)
@@ -4095,28 +4221,30 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
}
func normalizeSidebarOrder(order []string, projects []desktopProject) []string {
projectRoots := make(map[string]bool, len(projects))
for _, project := range projects {
if project.Root != "" {
projectRoots[project.Root] = true
}
}
seen := make(map[string]bool, len(order))
seenGlobal := false
// Dedupe roots against a roots-only list: out also holds the global order
// token, which must never be path-compared against project roots.
var seenRoots []string
out := make([]string, 0, len(order))
for _, value := range order {
value = strings.TrimSpace(value)
if value == desktopGlobalOrderToken {
if !seen[value] {
seen[value] = true
if !seenGlobal {
seenGlobal = true
out = append(out, value)
}
continue
}
root := normalizeProjectRoot(value)
if root == "" || !projectRoots[root] || seen[root] {
i := projectIndexByRoot(projects, root)
if i < 0 {
continue
}
seen[root] = true
root = projects[i].Root
if projectRootInList(seenRoots, root) {
continue
}
seenRoots = append(seenRoots, root)
out = append(out, root)
}
return out
@@ -4333,7 +4461,7 @@ func projectColor(root string) string {
return globalProjectColor()
}
for _, p := range loadProjectsFile().Projects {
if p.Root == root {
if sameProjectRoot(p.Root, root) {
return normalizeProjectColor(p.Color)
}
}
@@ -4359,11 +4487,19 @@ func addProject(root, title string) error {
title = strings.TrimSpace(title)
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
for i, p := range f.Projects {
if p.Root == root {
if title == "" || f.Projects[i].Title == title {
if sameProjectRoot(p.Root, root) {
changed := false
if f.Projects[i].Root != root {
f.Projects[i].Root = root
changed = true
}
if title != "" && f.Projects[i].Title != title {
f.Projects[i].Title = title
changed = true
}
if !changed {
return false, nil
}
f.Projects[i].Title = title
return true, nil
}
}
@@ -4384,10 +4520,11 @@ func renameProject(root, title string) error {
return true, nil
}
for i, p := range f.Projects {
if p.Root == root {
if f.Projects[i].Title == title {
if sameProjectRoot(p.Root, root) {
if f.Projects[i].Root == root && f.Projects[i].Title == title {
return false, nil
}
f.Projects[i].Root = root
f.Projects[i].Title = title
return true, nil
}
@@ -4409,10 +4546,11 @@ func setProjectColor(root, color string) error {
return true, nil
}
for i, p := range f.Projects {
if p.Root == root {
if f.Projects[i].Color == color {
if sameProjectRoot(p.Root, root) {
if f.Projects[i].Root == root && f.Projects[i].Color == color {
return false, nil
}
f.Projects[i].Root = root
f.Projects[i].Color = color
return true, nil
}
@@ -4427,7 +4565,7 @@ func removeProject(root string) error {
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
projects := make([]desktopProject, 0, len(f.Projects))
for _, p := range f.Projects {
if p.Root != root {
if !sameProjectRoot(p.Root, root) {
projects = append(projects, p)
}
}
@@ -5252,6 +5390,9 @@ func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
// unmarked so the next render retries instead of the gate hiding it forever.
deferred := false
for _, info := range infos {
if sessionOrderInfoIsAutomaticRecovery(info) {
continue
}
if strings.TrimSpace(info.TopicID) != "" {
continue
}
@@ -5415,11 +5556,8 @@ func repairIndexedSessionTopics(dir string) []string {
indexedTopics := projects.GlobalTopics
if scope == "project" {
indexedTopics = nil
for _, p := range projects.Projects {
if p.Root == workspaceRoot {
indexedTopics = p.Topics
break
}
if i := projectIndexByRoot(projects.Projects, workspaceRoot); i >= 0 {
indexedTopics = projects.Projects[i].Topics
}
}
indexedSet := make(map[string]bool, len(indexedTopics))
@@ -5432,6 +5570,9 @@ func repairIndexedSessionTopics(dir string) []string {
sourcesChanged := false
deferred := false
for _, info := range infos {
if sessionOrderInfoIsAutomaticRecovery(info) {
continue
}
topicID := strings.TrimSpace(info.TopicID)
if topicID == "" {
continue
@@ -5528,6 +5669,37 @@ func indexedSessionTopicTitle(sessionTitles map[string]string, info agent.Sessio
return topicTitleFromText(info.Preview)
}
func sessionOrderInfoIsAutomaticRecovery(info agent.SessionOrderInfo) bool {
return info.Recovered ||
strings.TrimSpace(info.RecoveryDigest) != "" ||
isAutomaticRecoverySessionPath(info.Path)
}
func sessionInfoIsAutomaticRecovery(info agent.SessionInfo) bool {
return info.Recovered ||
strings.TrimSpace(info.RecoveryDigest) != "" ||
isAutomaticRecoverySessionPath(info.Path)
}
func isAutomaticRecoverySessionPath(path string) bool {
id := agent.BranchID(path)
idx := strings.LastIndex(id, "-recovery-")
if idx <= 0 {
return false
}
suffix := id[idx+len("-recovery-"):]
if len(suffix) != 16 {
return false
}
for _, r := range suffix {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
continue
}
return false
}
return true
}
func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
dir = cleanDesktopPath(dir)
if dir == "" {
@@ -5558,9 +5730,9 @@ func legacySessionScopeMatchesMigrationTarget(meta agent.BranchMeta, scope, work
}
metaRoot := normalizeProjectRoot(meta.WorkspaceRoot)
if scope == "project" {
return metaRoot == "" || normalizeProjectRoot(workspaceRoot) == metaRoot
return metaRoot == "" || sameProjectRoot(workspaceRoot, metaRoot)
}
return metaRoot == "" || normalizeProjectRoot(globalWorkspaceRoot()) == metaRoot
return metaRoot == "" || sameProjectRoot(globalWorkspaceRoot(), metaRoot)
}
func cleanDesktopPath(path string) string {
@@ -5586,6 +5758,17 @@ func sameDesktopPath(a, b string) bool {
return a == b
}
// projectRootKey is the map-key form of a project root: cleaned, absolute,
// and case-folded on Windows — the same key form agent.CanonicalSessionPath
// uses for session paths, so equivalent spellings never split lookups.
func projectRootKey(root string) string {
root = cleanDesktopPath(root)
if os.PathSeparator == '\\' {
return strings.ToLower(root)
}
return root
}
func restoreSessionTopicIndex(dir, sessionPath string) error {
sessionPath = strings.TrimSpace(sessionPath)
if sessionPath == "" {
@@ -5754,6 +5937,7 @@ func (a *App) RenameProject(workspaceRoot, title string) error {
if err := renameProject(workspaceRoot, title); err != nil {
return err
}
a.syncTabWorkspaceRootSpellings()
a.emitProjectTreeChanged()
return nil
}
@@ -5764,6 +5948,7 @@ func (a *App) SetProjectColor(workspaceRoot, color string) error {
if err := setProjectColor(workspaceRoot, color); err != nil {
return err
}
a.syncTabWorkspaceRootSpellings()
a.emitProjectTreeChanged()
return nil
}
@@ -5776,19 +5961,19 @@ func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
return fmt.Errorf("workspaceRoot is required")
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
found := false
for _, project := range f.Projects {
if project.Root == root {
found = true
break
}
}
if !found {
i := projectIndexByRoot(f.Projects, root)
if i < 0 {
return false, fmt.Errorf("project %q not found", root)
}
next := removeString(f.PinnedProjects, root)
root = f.Projects[i].Root
next := make([]string, 0, len(f.PinnedProjects))
for _, pinnedRoot := range f.PinnedProjects {
if !sameProjectRoot(pinnedRoot, root) {
next = append(next, pinnedRoot)
}
}
if pinned {
next = prependUniqueString(f.PinnedProjects, root)
next = prependUniqueString(next, root)
}
if sameStringList(next, f.PinnedProjects) {
return false, nil
@@ -5806,36 +5991,32 @@ func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
// when present, the virtual Global sidebar section.
func (a *App) ReorderProjects(workspaceRoots []string) error {
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
byRoot := make(map[string]desktopProject, len(f.Projects))
for _, project := range f.Projects {
byRoot[project.Root] = project
}
seen := make(map[string]bool, len(workspaceRoots))
var seenProjects []string
next := make([]desktopProject, 0, len(workspaceRoots))
sidebarOrder := make([]string, 0, len(workspaceRoots))
hasGlobalOrder := false
for _, root := range workspaceRoots {
root = strings.TrimSpace(root)
if root == desktopGlobalOrderToken {
if seen[root] {
if hasGlobalOrder {
return false, fmt.Errorf("duplicate global section")
}
seen[root] = true
hasGlobalOrder = true
sidebarOrder = append(sidebarOrder, root)
continue
}
root = normalizeProjectRoot(root)
project, ok := byRoot[root]
if !ok {
i := projectIndexByRoot(f.Projects, root)
if i < 0 {
return false, fmt.Errorf("project %q not found", root)
}
if seen[root] {
project := f.Projects[i]
if projectRootInList(seenProjects, project.Root) {
return false, fmt.Errorf("duplicate project %q", root)
}
seen[root] = true
seenProjects = append(seenProjects, project.Root)
next = append(next, project)
sidebarOrder = append(sidebarOrder, root)
sidebarOrder = append(sidebarOrder, project.Root)
}
if len(next) != len(f.Projects) {
return false, fmt.Errorf("project order length mismatch")
@@ -6403,6 +6584,7 @@ func (a *App) ListProjectTree() []ProjectNode {
}
seenRuntimePaths[sessionPath] = true
info := sessionInfos[sessionPath]
recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath)
label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath])
status := activityStatusForTab(tab)
runtimeStatus := control.RuntimeStatus{}
@@ -6419,7 +6601,7 @@ func (a *App) ListProjectTree() []ProjectNode {
open: open,
running: running,
status: status,
recovered: info.Recovered,
recovered: recovered,
recoveryReason: info.RecoveryReason,
recoveryDigest: info.RecoveryDigest,
recoveryParentID: string(info.ParentID),
@@ -6619,7 +6801,10 @@ func topicSummaryKey(scope, workspaceRoot, topicID string) string {
if scope == "global" {
return "global::" + topicID
}
return "project:" + workspaceRoot + ":" + topicID
// Producers key by the live tab's root spelling while the sidebar keys by
// the registry's canonical spelling; fold both so runtime status never
// splits across equivalent roots.
return "project:" + projectRootKey(workspaceRoot) + ":" + topicID
}
func projectSessionNodeKey(scope, sessionPath string) string {
@@ -7533,7 +7718,7 @@ func (a *App) knownSessionDirs() []string {
func topicSessionMatchMatchesTarget(match topicSessionMatch, scope, workspaceRoot string) bool {
if scope == "project" {
return match.scope == "project" && normalizeProjectRoot(match.workspaceRoot) == normalizeProjectRoot(workspaceRoot)
return match.scope == "project" && sameProjectRoot(match.workspaceRoot, workspaceRoot)
}
return match.scope == "" || match.scope == "global"
}
@@ -7660,7 +7845,7 @@ func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]
key := topicSummaryKey(info.Scope, info.WorkspaceRoot, info.TopicID)
summary := topicSummaries[key]
lastActivityAt := info.LastActivityAt.UnixMilli()
if info.Recovered {
if sessionInfoIsAutomaticRecovery(info) {
// A conflict copy duplicates its original, so its turns would
// double-count — but its activity is real: once a tab adopts the
// copy as its live transcript, all new work lands here, and
+148
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 {
@@ -921,6 +933,142 @@ func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
}
}
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)
}
}
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"testing"
"reasonix/internal/event"
)
// Planner notices are persisted through plannerDisplay into session history;
// dropping Detail here would make the expandable diagnostics disappear after
// a reload even though the live transcript showed them.
func TestRecordPlannerDisplayEventKeepsNoticeDetail(t *testing.T) {
tab := &WorkspaceTab{}
tab.recordPlannerDisplayEvent(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Source: event.UsageSourcePlanner,
Text: "An MCP server failed to start.",
Detail: `mcp server "github" failed to start: command not found`,
})
if len(tab.plannerDisplay) != 1 {
t.Fatalf("plannerDisplay len = %d, want 1", len(tab.plannerDisplay))
}
got := tab.plannerDisplay[0]
if got.Role != "notice" || got.Level != "warn" {
t.Fatalf("unexpected notice message: %+v", got)
}
if got.Content != "An MCP server failed to start." {
t.Fatalf("Content = %q", got.Content)
}
if got.Detail != `mcp server "github" failed to start: command not found` {
t.Fatalf("Detail = %q, want diagnostic detail preserved", got.Detail)
}
}
+116
View File
@@ -462,6 +462,122 @@ func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
}
}
func TestLegacyRecoverySessionsDoNotMigrateIntoTopics(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
normal := writeLegacySession(t, dir, "normal.jsonl", "normal imported prompt", time.Now().Add(-2*time.Hour))
recovery := writeLegacySession(t, dir, "normal-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
nodes := NewApp().ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
t.Fatalf("project tree = %#v, want global folder", nodes)
}
if got := len(nodes[0].Children); got != 1 {
t.Fatalf("global migrated topics = %d, want only the normal session: %#v", got, nodes[0].Children)
}
if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(normal); got != want {
t.Fatalf("migrated topic = %q, want normal session topic %q", got, want)
}
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil {
t.Fatalf("load recovery meta: %v", err)
} else if ok && strings.TrimSpace(meta.TopicID) != "" {
t.Fatalf("legacy recovery branch was migrated into topic %q", meta.TopicID)
}
}
func TestHistoryMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: recovery, Label: "test"})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
sessions := app.ListSessions()
for _, session := range sessions {
if filepath.Clean(session.Path) != filepath.Clean(recovery) {
continue
}
if !session.Recovered {
t.Fatalf("history session recovered flag = false, want true: %+v", session)
}
return
}
t.Fatalf("history sessions = %#v, want recovery session %q", sessions, recovery)
}
func TestTrashMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
if err := deleteSessionFile(dir, recovery); err != nil {
t.Fatalf("delete recovery session: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(recovery), filepath.Base(recovery))
sessions := NewApp().ListTrashedSessions()
if len(sessions) != 1 || filepath.Clean(sessions[0].Path) != filepath.Clean(trashPath) {
t.Fatalf("trashed sessions = %#v, want %q", sessions, trashPath)
}
if !sessions[0].Recovered {
t.Fatalf("trashed recovery session recovered flag = false, want true: %+v", sessions[0])
}
}
func TestProjectTreeHidesAlreadyMigratedLegacyRecoveryTopic(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
topicID := legacySessionTopicID(recovery)
if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
ID: agent.BranchID(recovery),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: topicID,
TopicTitle: "恢复分支",
Turns: 1,
Preview: "legacy recovery prompt",
}); err != nil {
t.Fatalf("save migrated recovery meta: %v", err)
}
if err := setTopicTitle("", topicID, "恢复分支"); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicsInProjectsFile("", []string{topicID}, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
nodes := NewApp().ListProjectTree()
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.TopicID == topicID {
t.Fatalf("already-migrated recovery topic should be hidden, got node %+v", node)
}
walk(node.Children)
}
}
walk(nodes)
}
func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
+259
View File
@@ -14,6 +14,7 @@ import (
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"reasonix/internal/agent"
"reasonix/internal/checkpoint"
"reasonix/internal/config"
"reasonix/internal/control"
@@ -251,6 +252,264 @@ func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
}
}
func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
// A textually different spelling of the same folder. filepath.Join would
// clean the dot segment away and hand back the identical string, so build
// the spelling by hand.
equivalentRoot := projectRoot + string(filepath.Separator) + "."
f := normalizeProjectsFile(desktopProjectFile{
Projects: []desktopProject{
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
{Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}},
},
PinnedProjects: []string{equivalentRoot},
SidebarOrder: []string{equivalentRoot, projectRoot},
})
if len(f.Projects) != 1 {
t.Fatalf("projects = %+v, want one merged project", f.Projects)
}
if f.Projects[0].Root != normalizeProjectRoot(projectRoot) {
t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot))
}
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
}
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
}
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root {
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root)
}
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root {
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root)
}
}
func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
if err := removeProject(projectRoot); err != nil {
t.Fatalf("remove project: %v", err)
}
if got := loadProjectsFile().Projects; len(got) != 0 {
t.Fatalf("projects after remove = %+v, want none", got)
}
app := NewApp()
installNoopRuntimeEvents(app)
if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil {
t.Fatalf("switch workspace: %v", err)
} else if got != normalizeProjectRoot(projectRoot) {
t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot))
}
projects := loadProjectsFile().Projects
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) {
t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot))
}
if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) {
t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot))
}
}
// flipPathASCIICase returns the path with the case of every ASCII letter
// swapped — on Windows an equivalent spelling of the same folder that
// normalizeProjectRoot cannot fold away.
func flipPathASCIICase(t *testing.T, path string) string {
t.Helper()
flipped := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r - 'a' + 'A'
case r >= 'A' && r <= 'Z':
return r - 'A' + 'a'
}
return r
}, path)
if flipped == path {
t.Skipf("path %q contains no ASCII letters to flip", path)
}
return flipped
}
func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case-insensitive root matching only applies to Windows paths")
}
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
flipped := flipPathASCIICase(t, projectRoot)
f := normalizeProjectsFile(desktopProjectFile{
Projects: []desktopProject{
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
{Root: flipped, Color: "blue", Topics: []string{"topic_b"}},
},
PinnedProjects: []string{flipped},
SidebarOrder: []string{flipped, projectRoot},
})
if len(f.Projects) != 1 {
t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects)
}
canonical := f.Projects[0].Root
if canonical != normalizeProjectRoot(projectRoot) {
t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot))
}
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
}
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
}
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical {
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical)
}
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical {
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical)
}
}
func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case-insensitive root matching only applies to Windows paths")
}
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
flipped := flipPathASCIICase(t, projectRoot)
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
if err := addProject(flipped, ""); err != nil {
t.Fatalf("re-add project under flipped case: %v", err)
}
projects := loadProjectsFile().Projects
if len(projects) != 1 {
t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects)
}
if projects[0].Root != normalizeProjectRoot(flipped) {
t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped))
}
if projects[0].Title != "Project" {
t.Fatalf("title = %q, want preserved across re-add", projects[0].Title)
}
if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil {
t.Fatalf("prepend topic: %v", err)
}
projects = loadProjectsFile().Projects
if len(projects) != 1 {
t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects)
}
if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" {
t.Fatalf("topics = %v, want [topic_a] on the merged entry", got)
}
if err := removeProject(projectRoot); err != nil {
t.Fatalf("remove project via original spelling: %v", err)
}
if got := loadProjectsFile().Projects; len(got) != 0 {
t.Fatalf("projects after remove = %+v, want none", got)
}
}
func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case-insensitive root matching only applies to Windows paths")
}
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
flipped := flipPathASCIICase(t, projectRoot)
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := NewApp()
installNoopRuntimeEvents(app)
app.tabs["tab_case"] = &WorkspaceTab{
ID: "tab_case",
Scope: "project",
WorkspaceRoot: normalizeProjectRoot(projectRoot),
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabOrder = []string{"tab_case"}
// Re-registering under the flipped spelling self-heals the registry root;
// open tabs must follow so the frontend keeps comparing one string form.
app.registerProjectRoot(flipped)
projects := loadProjectsFile().Projects
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) {
t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped))
}
if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root {
t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root)
}
}
func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case-insensitive root matching only applies to Windows paths")
}
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
flipped := flipPathASCIICase(t, projectRoot)
// Register under original spelling.
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil {
t.Fatalf("prepend topic: %v", err)
}
// Write a session file with the original root spelling in its meta.
sessionDir := desktopSessionDir(projectRoot)
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(sessionDir, "topic-case.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session file: %v", err)
}
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
TopicID: "topic_case",
Scope: "project",
WorkspaceRoot: projectRoot,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
// Re-add under the flipped-case spelling — simulates Windows Explorer
// or a different shell returning the same folder with different case.
app := NewApp()
installNoopRuntimeEvents(app)
app.registerProjectRoot(flipped)
// findTopicSessionForTarget must match the session whose meta carries
// the original case spelling against the registry's new (flipped) root.
path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case")
if path == "" {
t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match")
}
if path != sessionPath {
t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath)
}
}
func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
parent := t.TempDir()
missing := filepath.Join(parent, "deleted", "project")
+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
+13 -6
View File
@@ -17,8 +17,8 @@ directory.
`https://github.com/obra/superpowers`.
- A GitHub branch or subdirectory URL, such as
`https://github.com/owner/repo/tree/main/path/to/plugin`.
- A local directory that contains `reasonix-plugin.json` or
`.codex-plugin/plugin.json`.
- A local directory that contains `reasonix-plugin.json`,
`.codex-plugin/plugin.json`, or `.claude-plugin/plugin.json`.
Preview the install plan without writing files:
@@ -222,12 +222,19 @@ Reasonix plugins can declare `reasonix-plugin.json` at the plugin root:
Relative paths are resolved inside the plugin root. Reasonix does not run
third-party install scripts during plugin installation.
## Codex Compatibility
## Codex & Claude Compatibility
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json`.
For packages such as Superpowers and Claude-style skill packs, Reasonix maps:
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json` and
Claude Marketplace manifests at `.claude-plugin/plugin.json`. Claude plugin
capabilities Reasonix does not map yet (`commands/`, `agents/`,
`hooks/hooks.json`, `.mcp.json`) surface as install warnings instead of being
silently dropped; multi-plugin `marketplace.json` indexes are not supported —
install each plugin directory individually. For packages such
as Superpowers and Claude-style skill packs, Reasonix maps:
- `skills` to Reasonix skill roots.
- `skills` to Reasonix skill roots. A Claude manifest that declares no
`skills` field falls back to the conventional `skills/` (or `.claude/skills/`)
directory, matching Claude's own auto-discovery.
- `hooks/session-start-codex` to the Reasonix `SessionStart` hook when present.
- A plugin-root `CLAUDE.md` file to a built-in `SessionStart` context hook. The
file is read directly by Reasonix, without spawning a shell command.
+10 -6
View File
@@ -15,8 +15,8 @@ Reasonix 插件包把 skills、hooks 和 MCP servers 组织成一个可安装单
`https://github.com/obra/superpowers`。
- GitHub 分支或子目录 URL,例如
`https://github.com/owner/repo/tree/main/path/to/plugin`。
- 本地目录,目录内需要包含 `reasonix-plugin.json` 或
`.codex-plugin/plugin.json`。
- 本地目录,目录内需要包含 `reasonix-plugin.json`、
`.codex-plugin/plugin.json` 或 `.claude-plugin/plugin.json`。
只预览安装计划,不写文件:
@@ -204,12 +204,16 @@ Reasonix 原生插件在根目录声明 `reasonix-plugin.json`:
相对路径都按插件根目录解析。Reasonix 安装插件时不会执行第三方安装脚本。
## Codex 兼容
## Codex 与 Claude 兼容
Reasonix 也会读取 `.codex-plugin/plugin.json`。对于 Superpowers 和 Claude 风格
skill 包,Reasonix 会映射:
Reasonix 也会读取 `.codex-plugin/plugin.json` 和 `.claude-plugin/plugin.json`。
Reasonix 尚未映射的 Claude 插件能力(`commands/`、`agents/`、`hooks/hooks.json`、
`.mcp.json`)会以安装警告的形式提示,而不是被静默丢弃;多插件的
`marketplace.json` 索引暂不支持——请逐个安装插件目录。
对于 Superpowers 和 Claude 风格 skill 包,Reasonix 会映射:
- `skills` 到 Reasonix skill root。
- `skills` 到 Reasonix skill root。Claude 清单若未声明 `skills` 字段,会回退到
约定目录 `skills/`(或 `.claude/skills/`),与 Claude 自身的自动发现一致。
- 如果存在 `hooks/session-start-codex`,映射为 Reasonix `SessionStart` hook。
- 插件根目录的 `CLAUDE.md` 会映射为内置的 `SessionStart` 上下文 hook。
Reasonix 会直接读取该文件,不通过 shell 命令。
+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
+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)
+74 -10
View File
@@ -24,6 +24,7 @@ import (
"reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/secrets"
"reasonix/internal/shellparse"
"reasonix/internal/tool"
)
@@ -343,6 +344,13 @@ type Agent struct {
// provider-visible execution contract. compact preserves the old injection.
memoryCompilerVerbosity string
compilerTurn *memorycompiler.Turn
// lastCompilerOutcome is the previous finished turn's persisted outcome.
// The immediately following user message may retroactively downgrade it
// when it reports the result wrong. Every non-synthetic turn start
// consumes it (one-shot) — even while the runtime is nil — so a ref can
// never survive intervening turns and be replayed after Memory v5 is
// re-enabled. Session switches clear it. Guarded by memoryCompilerMu.
lastCompilerOutcome *memorycompiler.OutcomeRef
// compilerInjectionMu bounds how often Memory v5 may replace a visible user
// turn with an execution contract. The runtime can still observe throttled
@@ -531,6 +539,25 @@ func (a *Agent) clearClassifierCache() {
}
}
// reviseMemoryCompilerOutcomeForFeedback retroactively downgrades the previous
// turn's recorded success when the user's immediate follow-up reports the
// result wrong. The ref is consumed unconditionally so it can never outlive
// the turn that follows it; the revision itself additionally requires a live
// runtime and corrective feedback.
func (a *Agent) reviseMemoryCompilerOutcomeForFeedback(rt *memorycompiler.Runtime, input string) {
a.memoryCompilerMu.Lock()
ref := a.lastCompilerOutcome
a.lastCompilerOutcome = nil
a.memoryCompilerMu.Unlock()
if ref == nil || rt == nil {
return
}
if !memorycompiler.IsCorrectiveFeedback(input) {
return
}
rt.ReviseOutcomeFromFeedback(*ref, input)
}
func shouldStartMemoryCompiler(input string) bool {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
@@ -682,6 +709,12 @@ func (a *Agent) SetSession(s *Session) {
a.rebuildTodoState(s.Snapshot())
}
a.resetMemoryCompilerInjectionGate()
// A session switch breaks the "immediately preceding turn" relationship:
// the next input belongs to a different conversation, so the pending
// outcome ref must not be revisable from it.
a.memoryCompilerMu.Lock()
a.lastCompilerOutcome = nil
a.memoryCompilerMu.Unlock()
// 清除分类缓存(会话边界)
a.clearClassifierCache()
}
@@ -990,6 +1023,12 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
memoryCompilerInput = sourceInput
}
input = a.withTurnPreferences(rawInput)
// Consume the previous turn's outcome ref on every non-synthetic turn,
// even while the runtime is nil (/memory-v5 off): revision must only ever
// target the immediately preceding turn.
if !MemoryCompilerSkipFromContext(ctx) {
a.reviseMemoryCompilerOutcomeForFeedback(a.memoryCompilerRuntime(), memoryCompilerInput)
}
if memCompiler := a.memoryCompilerRuntime(); memCompiler != nil && !MemoryCompilerSkipFromContext(ctx) && shouldStartMemoryCompiler(memoryCompilerInput) {
// 使用分类器判断是否为任务
isTask := true // 默认为任务
@@ -1015,6 +1054,10 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
a.emitMemoryCompilerStats(turn)
defer func() {
turn.Finish(runErr)
ref := turn.OutcomeRef()
a.memoryCompilerMu.Lock()
a.lastCompilerOutcome = &ref
a.memoryCompilerMu.Unlock()
if a.compilerTurn == turn {
a.compilerTurn = nil
}
@@ -1112,7 +1155,7 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
return fmt.Errorf("final-answer readiness failed %d times: %s", finalReadinessBlocks, readiness.reason)
}
event.RecordReadinessAudit(a.sink, readiness.audit(result, false))
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "final-answer readiness blocked: " + readiness.reason})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: finalReadinessNoticeText(), Detail: readiness.reason})
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(finalReadinessRetryMessage(readiness.reason))})
a.maybeCompact(ctx, usage)
continue
@@ -1122,14 +1165,14 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
if emptyFinalBlocks >= maxEmptyFinalBlocks {
return fmt.Errorf("model finished without a visible final answer %d times", emptyFinalBlocks)
}
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: emptyFinalNotice(a.prov.Name(), usage, len(reasoning))})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: emptyFinalNotice(), Detail: emptyFinalNoticeDetail(a.prov.Name(), usage, len(reasoning))})
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(emptyFinalRetryMessage())})
a.maybeCompact(ctx, usage)
continue
}
if executorHandoff && !usedAnyTool && handoffNudges < maxExecutorHandoffNudges && shouldNudgeExecutorHandoff(input, text) {
handoffNudges++
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "executor answered without taking any action; nudging it to use its tools"})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: executorHandoffNoticeText(), Detail: "executor answered without taking any action; nudging it to use its tools"})
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(executorHandoffRetryMessage())})
a.maybeCompact(ctx, usage)
continue
@@ -1180,7 +1223,7 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
graceRound = true
nudge := fmt.Sprintf("Do not call any more tools — your tool-call round limit (%s) has been reached. Instead, synthesize a final answer from all the work already completed: summarize what was accomplished, what remains to be done, and any decisions the user should make. The user can increase %s or continue in the next turn if more work is needed.", a.maxStepsKey, a.maxStepsKey)
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("budget (%s=%d) exhausted: one grace round to finalize", a.maxStepsKey, a.maxSteps)})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: toolBudgetNoticeText(), Detail: fmt.Sprintf("budget (%s=%d) exhausted: one grace round to finalize", a.maxStepsKey, a.maxSteps)})
}
}
// Only reached when a positive maxSteps guard is configured. The work so far
@@ -1334,6 +1377,10 @@ func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
return "latest successful todo_write still has incomplete items: " + strings.Join(parts, ", ")
}
func finalReadinessNoticeText() string {
return "Task status needs one more check; asking the assistant to finish or explain what is blocking it."
}
func (a *Agent) setTodoState(todos []evidence.TodoItem) {
a.todoMu.Lock()
a.todoState = append([]evidence.TodoItem(nil), todos...)
@@ -1681,7 +1728,11 @@ func emptyFinalRetryMessage() string {
return "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer to the user. Do not send reasoning only."
}
func emptyFinalNotice(prov string, u *provider.Usage, reasoningLen int) string {
func emptyFinalNotice() string {
return "No visible answer was produced; asking the assistant to respond again."
}
func emptyFinalNoticeDetail(prov string, u *provider.Usage, reasoningLen int) string {
finish := "unknown"
if u != nil && u.FinishReason != "" {
finish = u.FinishReason
@@ -1689,6 +1740,14 @@ func emptyFinalNotice(prov string, u *provider.Usage, reasoningLen int) string {
return fmt.Sprintf("empty final answer blocked: %s returned no visible answer text (finish=%s, reasoning=%d chars); retrying", prov, finish, reasoningLen)
}
func executorHandoffNoticeText() string {
return "The assistant answered before taking action; asking it to use the required tools."
}
func toolBudgetNoticeText() string {
return "Tool round limit reached; asking the assistant to summarize progress."
}
func streamRecoveryMessage(hasPartialText, hadPartialTool bool) string {
switch {
case hadPartialTool:
@@ -2136,7 +2195,7 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
}
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
var guard, detail string
if stormHit {
subject := fmt.Sprintf("%q", calls[0].Name)
short := calls[0].Name
@@ -2160,22 +2219,26 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
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(
detail = 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(
detail = fmt.Sprintf(
"loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
a.blockedTurnStreak)
}
results[0] = outcomes[0].output + "\n\n" + guard
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: notice})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: loopGuardNoticeText(), Detail: detail})
a.armLoopGuardPass(receiptMark)
}
func loopGuardNoticeText() string {
return "The assistant is stuck retrying a blocked action; asking it to change approach."
}
// batchStormSignature returns a per-turn fixation signature — each call's
// (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
@@ -2356,9 +2419,10 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
}
callID := call.ID
cctx = tool.WithProgress(cctx, func(chunk string) {
a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: chunk}})
a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: secrets.RedactToolOutput(chunk)}})
})
result, err := t.Execute(cctx, json.RawMessage(call.Arguments))
result = secrets.RedactToolOutput(result)
if a.evidence != nil {
if call.Name == "complete_step" {
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, t.ReadOnly())
+1 -1
View File
@@ -254,7 +254,7 @@ func TestCacheHitSurvivesTooSmallWindow(t *testing.T) {
paused := false
for _, n := range sink.notices {
t.Logf("notice: %s", n)
if strings.Contains(n, "Auto-compaction paused") {
if strings.Contains(n, "Automatic context cleanup paused") {
paused = true
}
}
+5 -4
View File
@@ -93,7 +93,8 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
// rewriting the prefix — a compaction here would needlessly crater the cache.
if u.PromptTokens >= soft && u.PromptTokens < snip && !a.softCompactNoticed {
a.softCompactNoticed = true
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("context reached %.0f%% of window; keeping cache-first prefix until compact threshold %.0f%%", a.softCompactRatio*100, a.compactRatio*100)})
detail := fmt.Sprintf("context reached %.0f%% of window; keeping cache-first prefix until compact threshold %.0f%%", a.softCompactRatio*100, a.compactRatio*100)
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context is getting large; preserving cache until cleanup is needed.", Detail: detail})
return
}
if u.PromptTokens >= snip && u.PromptTokens < high {
@@ -128,7 +129,7 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
}
}
if err := a.compact(ctx, "auto", "", force); err != nil {
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("compaction skipped: %v", err)})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context cleanup skipped for now.", Detail: fmt.Sprintf("compaction skipped: %v", err)})
return
}
// A healthy compaction drops the prompt under the trigger, so the next turn
@@ -139,7 +140,7 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
a.consecutiveCompacts++
if a.consecutiveCompacts >= 2 {
a.compactStuck = true
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Automatic context cleanup paused because the context window is too small.", Detail: fmt.Sprintf(
"context_window=%d is too small for compaction to help (the system prompt plus one turn already exceeds %.0f%% of it); raise context_window or shrink tool output. Auto-compaction paused until the prompt drops.",
a.contextWindow, a.compactRatio*100)})
}
@@ -258,7 +259,7 @@ func (a *Agent) compact(ctx context.Context, trigger, instructions string, force
// deterministic marker rather than aborting. /compact then always frees
// context (and auto-compaction can't loop on a still-full window); the
// verbatim user turns kept above are untouched.
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "compaction summary unavailable (" + err.Error() + "); folded mechanically"})
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context was compacted without a generated summary.", Detail: "compaction summary unavailable (" + err.Error() + "); folded mechanically"})
summary = mechanicalFoldDigest(len(fold), archived)
}
+1 -1
View File
@@ -99,7 +99,7 @@ func compactionsPerTurn(t *testing.T, windowTok int, blob, finalText string, tur
case event.CompactionStarted:
started++
case event.Notice:
if strings.Contains(e.Text, "Auto-compaction paused") {
if strings.Contains(e.Text, "Automatic context cleanup paused") {
paused = true
}
if strings.Contains(e.Text, "pruned") {
+1 -1
View File
@@ -663,7 +663,7 @@ func TestMaybeCompactThreshold(t *testing.T) {
if len(prov.got) != 0 {
t.Fatalf("soft threshold called summarizer: %+v", prov.got)
}
if len(notices) != 1 || !strings.Contains(notices[0].Text, "context reached 50%") {
if len(notices) != 1 || notices[0].Text != "Context is getting large; preserving cache until cleanup is needed." || !strings.Contains(notices[0].Detail, "context reached 50%") {
t.Fatalf("soft threshold notice = %+v", notices)
}
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 60})
+1 -1
View File
@@ -62,7 +62,7 @@ func hostAdvances(sink *recordSink) int {
func readinessBlocked(sink *recordSink) bool {
for _, e := range sink.kinds(event.Notice) {
if strings.Contains(e.Text, "readiness blocked") {
if e.Text == finalReadinessNoticeText() && strings.Contains(e.Detail, "latest successful todo_write") {
return true
}
}
+2 -2
View File
@@ -433,7 +433,7 @@ func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
{toolCallChunk("b3", "bash", `{"command":"ls -la"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
}}
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(prov, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, sink)
@@ -454,7 +454,7 @@ func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
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")
t.Fatal("loop guard should emit a user-facing notice")
}
}
+12
View File
@@ -99,6 +99,18 @@ func TestFinalReadinessCheckAuditsIncompleteTodos(t *testing.T) {
}
}
func TestFinalReadinessNoticeTextHidesInternalReason(t *testing.T) {
msg := finalReadinessNoticeText()
for _, hidden := range []string{"final-answer", "readiness blocked", "todo_write", "latest successful"} {
if strings.Contains(strings.ToLower(msg), hidden) {
t.Fatalf("finalReadinessNoticeText() = %q, should not expose %q", msg, hidden)
}
}
if !strings.Contains(strings.ToLower(msg), "finish or explain") {
t.Fatalf("finalReadinessNoticeText() = %q, want user-facing recovery action", msg)
}
}
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"}}
+13 -8
View File
@@ -83,17 +83,22 @@ func TestFinishReasonMessage(t *testing.T) {
}
}
// TestEmptyFinalNotice carries the diagnostics that tell the three empty-answer
// causes apart in reports: which provider, how it stopped, and whether the model
// produced reasoning-only output.
// TestEmptyFinalNotice keeps the user-facing line short while preserving the
// diagnostics that tell empty-answer causes apart in expandable details.
func TestEmptyFinalNotice(t *testing.T) {
msg := emptyFinalNotice("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512)
for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} {
if !strings.Contains(msg, want) {
t.Errorf("notice %q missing %q", msg, want)
msg := emptyFinalNotice()
for _, hidden := range []string{"blocked", "finish=", "reasoning="} {
if strings.Contains(msg, hidden) {
t.Errorf("notice %q should not expose internal diagnostic %q", msg, hidden)
}
}
if got := emptyFinalNotice("p", nil, 0); !strings.Contains(got, "finish=unknown") {
detail := emptyFinalNoticeDetail("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512)
for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} {
if !strings.Contains(detail, want) {
t.Errorf("notice detail %q missing %q", detail, want)
}
}
if got := emptyFinalNoticeDetail("p", nil, 0); !strings.Contains(got, "finish=unknown") {
t.Errorf("nil usage should report finish=unknown, got %q", got)
}
}
+168
View File
@@ -0,0 +1,168 @@
package agent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/internal/agent/testutil"
"reasonix/internal/event"
"reasonix/internal/memorycompiler"
)
// readCompilerStrategies decodes the persisted Memory v5 strategy counters so
// the test can observe learning-side effects without exporting runtime state.
func readCompilerStrategies(t *testing.T, dir string) []memorycompiler.Strategy {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatalf("read state: %v", err)
}
var st struct {
Strategies []memorycompiler.Strategy `json:"strategies"`
}
if err := json.Unmarshal(b, &st); err != nil {
t.Fatalf("unmarshal state: %v", err)
}
return st.Strategies
}
// requireBugfixCounters asserts the persisted bugfix strategy's success/failure
// counters.
func requireBugfixCounters(t *testing.T, dir, when string, successes, failures int) {
t.Helper()
for _, s := range readCompilerStrategies(t, dir) {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.Successes != successes || s.Failures != failures {
t.Fatalf("%s: counters = %d ok / %d fail, want %d ok / %d fail (%+v)",
when, s.Successes, s.Failures, successes, failures, s)
}
return
}
t.Fatalf("%s: bugfix strategy missing from state", when)
}
func TestCorrectiveFollowUpRevisesPreviousOutcome(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m", testutil.Turn{Text: "done"}, testutil.Turn{Text: "sorry"})
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID == "bugfix-reproduce-first" && (s.Successes != 1 || s.Failures != 0) {
t.Fatalf("turn 1 not recorded as success: %+v", s)
}
}
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
t.Fatalf("turn 2: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.Successes != 0 || s.Failures < 1 {
t.Fatalf("corrective follow-up did not revise turn 1 outcome: %+v", s)
}
return
}
t.Fatal("bugfix strategy missing from state")
}
func TestNeutralFollowUpDoesNotReviseOutcome(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m", testutil.Turn{Text: "done"}, testutil.Turn{Text: "done again"})
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
if err := a.Run(context.Background(), "fix another bug in the lexer"); err != nil {
t.Fatalf("turn 2: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.Successes != 2 || s.Failures != 0 {
t.Fatalf("neutral follow-up moved counters: %+v", s)
}
return
}
t.Fatal("bugfix strategy missing from state")
}
// TestStaleOutcomeRefNotRevisableAfterCompilerToggle covers the reviewer
// scenario: success turn records a ref → /memory-v5 off → intervening turn →
// on again → corrective input. The intervening turn must consume the ref even
// though the runtime was nil, so the old trace is not downgraded.
func TestStaleOutcomeRefNotRevisableAfterCompilerToggle(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m",
testutil.Turn{Text: "done"},
testutil.Turn{Text: "chat answer"},
testutil.Turn{Text: "sorry"},
)
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
requireBugfixCounters(t, dir, "after turn 1", 1, 0)
a.SetMemoryCompiler(nil) // /memory-v5 off
if err := a.Run(context.Background(), "tell me about the weather"); err != nil {
t.Fatalf("intervening turn: %v", err)
}
a.SetMemoryCompiler(memorycompiler.New(dir)) // /memory-v5 on
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
t.Fatalf("corrective turn: %v", err)
}
// The corrective input must not revise turn 1: its ref was consumed by the
// intervening turn. (The corrective turn itself may add new samples, so
// only assert turn 1's success survived.)
for _, s := range readCompilerStrategies(t, dir) {
if s.ID == "bugfix-reproduce-first" && s.Successes != 1 {
t.Fatalf("stale ref revised turn 1 after compiler toggle: %+v", s)
}
}
}
// TestOutcomeRefClearedOnSessionSwitch: switching sessions breaks the
// "immediately preceding turn" relationship, so the first input of the new
// session must not revise the old session's outcome even without intervening
// turns.
func TestOutcomeRefClearedOnSessionSwitch(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m",
testutil.Turn{Text: "done"},
testutil.Turn{Text: "sorry"},
)
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
requireBugfixCounters(t, dir, "after turn 1", 1, 0)
a.SetSession(NewSession("")) // resume/switch to another conversation
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
t.Fatalf("new-session turn: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID == "bugfix-reproduce-first" && s.Successes != 1 {
t.Fatalf("new session's first input revised the old session's outcome: %+v", s)
}
}
}
+42 -17
View File
@@ -19,6 +19,7 @@ import (
"reasonix/internal/fileutil"
"reasonix/internal/provider"
"reasonix/internal/secrets"
"reasonix/internal/store"
)
@@ -200,7 +201,17 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
// 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 := s.snapshotWithVersion()
msgs, version, rewriteVersion := s.snapshotWithVersion()
// Durable transcripts are always redacted, independent of the live
// [secrets] redact_tool_output toggle. Digest consistency holds because
// Redact is deterministic and idempotent (see secrets.Redact): a loaded
// session's messages are already redacted, so re-redacting them here is a
// byte-for-byte no-op and the snapshot keeps its prefix relationship to
// the on-disk transcript — the same digest/prefix machinery that guards
// against bogus stale-prefix conflicts (#6083) sees identical bytes. The
// persisted baseline (markPersisted) is likewise recorded over the
// redacted form, matching what LoadSession will digest back.
msgs = secrets.RedactMessages(msgs)
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
if err != nil {
return err
@@ -249,10 +260,10 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
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
}
s.markPersisted(path, digest, version, decision.revision)
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
return nil
}
if decision.appendOnly && probe.native {
@@ -289,7 +300,7 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
// 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
@@ -351,7 +362,7 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
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
}
@@ -540,7 +551,12 @@ 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()
// Same redaction contract as save(): recovery branches are durable
// transcripts too, and the coverage checks below compare against on-disk
// content that save() already redacted — comparing a raw snapshot against
// it would misread pure coverage as divergence and fork a bogus branch.
msgs = secrets.RedactMessages(msgs)
preview, turns := SessionPreviewFromMessages(msgs)
if turns == 0 {
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
@@ -626,7 +642,7 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
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) {
@@ -663,7 +679,7 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
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
}
@@ -808,19 +824,19 @@ func (s *Session) persistState(path string) sessionPersistState {
return sessionPersistState{}
}
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64) {
s.setPersistedBaseline(path, digest, version, revision, true)
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) {
s.setPersistedBaseline(path, digest, version, 0, false)
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) {
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{
@@ -831,6 +847,12 @@ func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, ve
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
@@ -1119,13 +1141,13 @@ func LoadSession(path string) (*Session, error) {
// 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.markPersistedRevisionUnknown(path, digest, s.version, s.rewriteVersion)
} else {
revision := int64(0)
if ok {
revision = meta.Revision
}
s.markPersisted(path, digest, s.version, revision)
s.markPersisted(path, digest, s.version, revision, s.rewriteVersion)
}
}
return s, nil
@@ -1471,7 +1493,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 {
@@ -1595,6 +1617,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)},
} {
@@ -1620,7 +1645,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 {
+144 -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
@@ -67,6 +68,91 @@ func TestSaveLoadRoundTrip(t *testing.T) {
}
}
func TestSaveRedactsSecretsOnDisk(t *testing.T) {
secret := "sk-real-secret-value-123456"
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
s.Add(provider.Message{
Role: provider.RoleTool,
Name: "bash",
ToolCallID: "call_1",
Content: "DEEPSEEK_API_KEY=" + secret + "\n",
})
path := filepath.Join(t.TempDir(), "redacted.jsonl")
if err := s.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved session: %v", err)
}
if strings.Contains(string(body), secret) {
t.Fatalf("session persisted raw secret:\n%s", body)
}
if !strings.Contains(string(body), "DEEPSEEK_API_KEY=sk-rea") {
t.Fatalf("session did not persist a masked credential marker:\n%s", body)
}
}
// TestSaveRedactionKeepsSnapshotBaselineStable is the digest-consistency
// contract behind redact-on-save: because Redact is idempotent, a loaded
// (already-redacted) transcript re-saves as the exact same bytes, so
// SaveSnapshot must take the up-to-date path — no rewrite, no revision bump,
// and above all no bogus snapshot conflict / recovery fork (#6083's failure
// shape). Appending after the resave must land as a plain append.
func TestSaveRedactionKeepsSnapshotBaselineStable(t *testing.T) {
secret := "sk-real-secret-value-123456"
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
s.Add(provider.Message{
Role: provider.RoleTool,
Name: "bash",
ToolCallID: "call_1",
Content: "DEEPSEEK_API_KEY=" + secret + "\n",
})
path := filepath.Join(t.TempDir(), "stable.jsonl")
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("first SaveSnapshot: %v", err)
}
rev1, _, err := sessionContentRevision(path)
if err != nil {
t.Fatalf("read revision: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
// Re-saving the loaded transcript re-runs RedactMessages over content that
// is already masked; idempotence means identical bytes and the up-to-date
// skip, not a conflict or a revision bump.
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("resave of loaded redacted session: %v", err)
}
rev2, _, err := sessionContentRevision(path)
if err != nil {
t.Fatalf("read revision after resave: %v", err)
}
if rev1 != rev2 {
t.Fatalf("no-op resave bumped revision %d -> %d; redaction broke digest stability", rev1, rev2)
}
// A continued conversation still append-saves cleanly on top.
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
if err := loaded.SaveSnapshot(path); err != nil {
t.Fatalf("append snapshot after redacted round-trip: %v", err)
}
reloaded, err := LoadSession(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := len(reloaded.Messages); got != 4 {
t.Fatalf("message count after append = %d, want 4", got)
}
}
func TestSaveLoadLargeMessage(t *testing.T) {
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "run it"})
@@ -797,7 +883,7 @@ func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
}
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true)
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)
}
@@ -845,7 +931,7 @@ func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *te
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)
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)
}
@@ -2010,3 +2096,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())
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type secretOutputTool struct{}
func (secretOutputTool) Name() string { return "secret_output" }
func (secretOutputTool) Description() string { return "" }
func (secretOutputTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (secretOutputTool) ReadOnly() bool { return true }
func (secretOutputTool) Execute(context.Context, json.RawMessage) (string, error) {
return "DEEPSEEK_API_KEY=sk-real-secret-value-123456\n", nil
}
func TestExecuteOneRedactsToolResultBeforeHistory(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(secretOutputTool{})
a := &Agent{tools: reg, session: NewSession("")}
outcome := a.executeOne(context.Background(), provider.ToolCall{ID: "call_1", Name: "secret_output", Arguments: `{}`})
if strings.Contains(outcome.output, "sk-real-secret-value-123456") {
t.Fatalf("tool outcome leaked raw secret:\n%s", outcome.output)
}
if !strings.Contains(outcome.output, "DEEPSEEK_API_KEY=sk-rea") {
t.Fatalf("tool outcome missing masked marker:\n%s", outcome.output)
}
}
+40 -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
@@ -67,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
}
@@ -97,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,
}
}
@@ -124,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.
@@ -146,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()
+1 -1
View File
@@ -585,7 +585,7 @@ func TestSameContentRetryHealsLedgerForSurvivingSaver(t *testing.T) {
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)
s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true, 0)
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("autosave retry: %v", err)
+70
View File
@@ -402,3 +402,73 @@ func TestNewSessionPathEmptyModel(t *testing.T) {
t.Errorf("empty model should use 'session' fallback: %s", path)
}
}
// --- rewrite-save baseline ---
// TestNeedsRewriteSaveFollowsSaves pins the baseline's lifecycle on the
// session object itself: an in-memory rewrite demands a rewrite save, every
// full save re-anchors (including the plain force Save the depth-cap recovery
// path uses), and the baseline never moves backwards when a slower save
// reports an older capture.
func TestNeedsRewriteSaveFollowsSaves(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
if s.NeedsRewriteSave() {
t.Fatal("fresh session should not need a rewrite save")
}
s.IncrementRewrite()
if !s.NeedsRewriteSave() {
t.Fatal("in-memory rewrite must demand a rewrite save")
}
if err := s.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
if s.NeedsRewriteSave() {
t.Fatal("force save must re-anchor the rewrite baseline")
}
s.IncrementRewrite()
if err := s.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite: %v", err)
}
if s.NeedsRewriteSave() {
t.Fatal("SaveRewrite must re-anchor the rewrite baseline")
}
// A slower save that captured an older rewriteVersion must not roll the
// baseline back below what a faster save already persisted.
digest, err := digestSessionMessages(s.Snapshot())
if err != nil {
t.Fatalf("digest: %v", err)
}
s.markPersisted(path, digest, 1, 1, 0)
if s.NeedsRewriteSave() {
t.Fatal("stale capture rolled the rewrite baseline backwards")
}
}
// TestRewriteBaselineStaysWithClones: an unpersisted rewrite travels with the
// clone, and the source persisting later does not mark the clone's copy as
// saved — each session object owns its own baseline, so no swap can orphan or
// misattribute it.
func TestRewriteBaselineStaysWithClones(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
s.IncrementRewrite()
clone := s.CloneWithMessages(s.Snapshot())
if !clone.NeedsRewriteSave() {
t.Fatal("clone must inherit the unpersisted rewrite")
}
if err := s.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
if s.NeedsRewriteSave() {
t.Fatal("source baseline not re-anchored by save")
}
if !clone.NeedsRewriteSave() {
t.Fatal("saving the source must not mark the clone's rewrite persisted")
}
}
+17 -17
View File
@@ -35,10 +35,10 @@ func (o okTool) Schema() json.RawMessage { retu
func (o okTool) ReadOnly() bool { return true }
func (o okTool) Execute(context.Context, json.RawMessage) (string, error) { return "ok", nil }
func warnNoticeRecorder() (event.Sink, *[]string) {
func noticeRecorder() (event.Sink, *[]string) {
var notices []string
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice && e.Level == event.LevelWarn {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
})
@@ -48,13 +48,13 @@ func warnNoticeRecorder() (event.Sink, *[]string) {
// TestStormBreakerEscalatesRepeatedFailure: once the same tool has failed the
// same way stormBreakThreshold times in a row, the model-facing result must carry
// the loop-guard directive (not just the raw error again), and the user must get
// a warn notice. The arguments DIFFER on every call — mirroring the live failure
// a notice. The arguments DIFFER on every call — mirroring the live failure
// mode where a stuck model re-words the payload — to prove detection keys on the
// error, not the bytes.
func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
args := []string{`{"content":"Mountains are`, `{"path":"n.txt","content":"Peaks rise`, `{}`}
@@ -74,7 +74,7 @@ func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
t.Errorf("loop-guard result should still preserve the original error, got: %q", last)
}
if len(*notices) == 0 {
t.Errorf("loop guard should emit a warn notice to the user")
t.Errorf("loop guard should emit a notice to the user")
}
}
@@ -86,7 +86,7 @@ func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
func TestStormBreakerEscalatesRepeatedBlockedPermission(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, sink)
@@ -109,7 +109,7 @@ func TestStormBreakerEscalatesRepeatedBlockedPermission(t *testing.T) {
t.Fatalf("permission loop guard should preserve blocked context, got: %q", last)
}
if len(*notices) == 0 {
t.Errorf("loop guard should emit a warn notice to the user")
t.Errorf("loop guard should emit a notice to the user")
}
}
@@ -121,7 +121,7 @@ func TestStormBreakerEscalatesAlternatingBlockedShapes(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(fakeTool{name: "web_fetch", readOnly: false})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true, "web_fetch": true}},
}, sink)
@@ -143,7 +143,7 @@ func TestStormBreakerEscalatesAlternatingBlockedShapes(t *testing.T) {
t.Fatal("streak guard should arm the final-readiness loop-guard pass")
}
if len(*notices) == 0 {
t.Errorf("streak loop guard should emit a warn notice to the user")
t.Errorf("streak loop guard should emit a notice to the user")
}
}
@@ -183,7 +183,7 @@ func TestStormBreakerEscalatesRepeatedBatch(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_a"})
reg.Add(failTool{name: "write_b"})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
batch := []provider.ToolCall{
@@ -202,7 +202,7 @@ func TestStormBreakerEscalatesRepeatedBatch(t *testing.T) {
t.Errorf("guard should name the repeated batch, got: %q", first)
}
if len(*notices) == 0 {
t.Errorf("loop guard should emit a warn notice for a repeated batch")
t.Errorf("loop guard should emit a notice for a repeated batch")
}
}
@@ -213,7 +213,7 @@ func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
reg.Add(okTool{name: "read_file"})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
batch := []provider.ToolCall{
@@ -229,7 +229,7 @@ func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
t.Fatalf("a batch with a succeeding call should never trip the guard, got: %q", first)
}
if len(*notices) != 0 {
t.Errorf("no warn notice expected when part of the batch succeeds, got %v", *notices)
t.Errorf("no notice expected when part of the batch succeeds, got %v", *notices)
}
}
@@ -238,7 +238,7 @@ func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
func TestStormBreakerSilentBelowThreshold(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
call := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
@@ -251,7 +251,7 @@ func TestStormBreakerSilentBelowThreshold(t *testing.T) {
t.Fatalf("guard fired after only %d repeats (threshold %d)", stormBreakThreshold-1, stormBreakThreshold)
}
if len(*notices) != 0 {
t.Errorf("no warn notice expected below threshold, got %v", *notices)
t.Errorf("no notice expected below threshold, got %v", *notices)
}
}
@@ -261,7 +261,7 @@ func TestStormBreakerResetsOnSuccess(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
reg.Add(okTool{name: "read_file"})
sink, notices := warnNoticeRecorder()
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
fail := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
@@ -278,6 +278,6 @@ func TestStormBreakerResetsOnSuccess(t *testing.T) {
t.Fatalf("guard should have reset after a successful turn, got: %q", last)
}
if len(*notices) != 0 {
t.Errorf("no warn notice expected when a success breaks the run, got %v", *notices)
t.Errorf("no notice expected when a success breaks the run, got %v", *notices)
}
}
+239
View File
@@ -0,0 +1,239 @@
package autoresearch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestDirectionFingerprintDistinguishesLongPrefixes: two directions sharing a
// >56-char slug prefix used to collapse to one fingerprint, wrongly counting
// the second as a repeat and inflating StaleCount toward a forced pivot.
func TestDirectionFingerprintDistinguishesLongPrefixes(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Long prefix fingerprints", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
prefix := "Benchmark the desktop frontend markdown rendering pipeline for "
progress, err := store.RecordDirection(task.ID, Direction{
Summary: prefix + "large tables",
AcceptedEvidenceIDs: []string{"f1"},
Now: time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection first: %v", err)
}
if progress.StaleCount != 0 {
t.Fatalf("first direction stale = %d, want 0", progress.StaleCount)
}
progress, err = store.RecordDirection(task.ID, Direction{
Summary: prefix + "code blocks",
AcceptedEvidenceIDs: []string{"f2"},
Now: time.Date(2026, 7, 7, 10, 1, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection second: %v", err)
}
// A genuinely different direction with accepted evidence must not be
// counted as a repeat.
if progress.StaleCount != 0 {
t.Fatalf("distinct long-prefix direction treated as repeat: stale = %d, want 0", progress.StaleCount)
}
// An exact repeat must still be detected.
progress, err = store.RecordDirection(task.ID, Direction{
Summary: prefix + "code blocks",
AcceptedEvidenceIDs: []string{"f3"},
Now: time.Date(2026, 7, 7, 10, 2, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection third: %v", err)
}
if progress.StaleCount != 1 {
t.Fatalf("exact repeat not detected: stale = %d, want 1", progress.StaleCount)
}
}
// TestDirectionFingerprintDistinguishesCJK: directions differing only in CJK
// text slugify to the same string ("task") and used to collide.
func TestDirectionFingerprintDistinguishesCJK(t *testing.T) {
a := directionFingerprint("评测甲方案的渲染性能")
b := directionFingerprint("评测乙方案的渲染性能")
if a == b {
t.Fatalf("CJK-only-diff directions share a fingerprint: %q", a)
}
if directionFingerprint("评测甲方案的渲染性能") != a {
t.Fatal("fingerprint is not deterministic")
}
}
// TestDirectionFingerprintBackwardCompatShortASCII: short ASCII summaries keep
// the bare slug so fingerprints recorded by older versions still match.
func TestDirectionFingerprintBackwardCompatShortASCII(t *testing.T) {
got := directionFingerprint("Profile markdown rendering")
want := slugify("Profile markdown rendering")
if got != want {
t.Fatalf("short ASCII fingerprint = %q, want legacy slug %q", got, want)
}
}
// TestRecordDirectionMigratesLegacyFingerprint: a directions_tried.json entry
// written by an older version (bare truncated slug) must still match its own
// summary on repeat, not be double-counted as a new direction.
func TestRecordDirectionMigratesLegacyFingerprint(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Legacy fingerprint migration", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables"
// Simulate a legacy entry: fingerprint from the old bare slugify.
dirPath := filepath.Join(task.Root, "state", "directions_tried.json")
legacy := []DirectionTried{{
Fingerprint: slugify(summary),
Summary: summary,
FirstSeenIteration: 1,
LastSeenIteration: 1,
Count: 1,
}}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dirPath, data, 0o644); err != nil {
t.Fatal(err)
}
progress, err := store.RecordDirection(task.ID, Direction{
Summary: summary,
Now: time.Date(2026, 7, 7, 11, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection: %v", err)
}
// Repeat of the legacy direction: stale should increment (repeat + no
// accepted evidence), and the entry should be migrated, not duplicated.
if progress.StaleCount != 1 {
t.Fatalf("legacy repeat not detected: stale = %d, want 1", progress.StaleCount)
}
raw, err := os.ReadFile(dirPath)
if err != nil {
t.Fatal(err)
}
var directions []DirectionTried
if err := json.Unmarshal(raw, &directions); err != nil {
t.Fatal(err)
}
if len(directions) != 1 {
t.Fatalf("directions = %+v, want single migrated entry", directions)
}
if directions[0].Count != 2 {
t.Fatalf("migrated count = %d, want 2", directions[0].Count)
}
if directions[0].Fingerprint != directionFingerprint(summary) {
t.Fatalf("entry not migrated to new fingerprint: %q", directions[0].Fingerprint)
}
}
// TestTailJSONLLinesMatchesFullScan: the tail reader must return exactly the
// same entries as a full scan for every limit, including limits larger than
// the file and files bigger than one read chunk.
func TestTailJSONLLinesMatchesFullScan(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Tail read equivalence", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// Write enough heartbeats that the log exceeds one 64KiB chunk.
long := strings.Repeat("x", 700)
for i := 0; i < 150; i++ {
if err := store.AppendHeartbeat(task.ID, Heartbeat{
Status: HeartbeatTurnDone,
Iteration: i + 1,
Message: fmt.Sprintf("turn-%03d %s", i, long),
CreatedAt: time.Date(2026, 7, 7, 12, 0, i%60, 0, time.UTC),
}); err != nil {
t.Fatalf("AppendHeartbeat %d: %v", i, err)
}
}
all, err := store.Heartbeats(task.ID, 0)
if err != nil {
t.Fatalf("Heartbeats(0): %v", err)
}
if len(all) != 150 {
t.Fatalf("full scan = %d heartbeats, want 150", len(all))
}
for _, limit := range []int{1, 3, 149, 150, 500} {
got, err := store.Heartbeats(task.ID, limit)
if err != nil {
t.Fatalf("Heartbeats(%d): %v", limit, err)
}
want := all
if limit < len(all) {
want = all[len(all)-limit:]
}
if len(got) != len(want) {
t.Fatalf("Heartbeats(%d) = %d entries, want %d", limit, len(got), len(want))
}
for i := range got {
if got[i].Iteration != want[i].Iteration {
t.Fatalf("Heartbeats(%d)[%d].Iteration = %d, want %d", limit, i, got[i].Iteration, want[i].Iteration)
}
}
}
// LastHeartbeat must be the newest entry.
last, ok, err := store.LastHeartbeat(task.ID)
if err != nil || !ok {
t.Fatalf("LastHeartbeat: ok=%v err=%v", ok, err)
}
if last.Iteration != 150 {
t.Fatalf("LastHeartbeat iteration = %d, want 150", last.Iteration)
}
}
// TestFindingsBoundedTailMatchesFullScan mirrors the heartbeat check for the
// findings log, which desktop views read with a limit.
func TestFindingsBoundedTailMatchesFullScan(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Findings tail equivalence", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
for i := 0; i < 25; i++ {
if err := store.AppendFinding(task.ID, Finding{
ID: fmt.Sprintf("f%03d", i),
Kind: FindingKindManual,
Summary: fmt.Sprintf("finding %03d", i),
Accepted: i%2 == 0,
CreatedAt: time.Date(2026, 7, 7, 13, 0, i%60, 0, time.UTC),
}); err != nil {
t.Fatalf("AppendFinding %d: %v", i, err)
}
}
all, err := store.Findings(task.ID, 0)
if err != nil {
t.Fatalf("Findings(0): %v", err)
}
if len(all) != 25 || all[0].ID != "f024" {
t.Fatalf("full scan = %d findings first %q, want 25 / f024 (newest first)", len(all), all[0].ID)
}
got, err := store.Findings(task.ID, 5)
if err != nil {
t.Fatalf("Findings(5): %v", err)
}
if len(got) != 5 || got[0].ID != "f024" || got[4].ID != "f020" {
t.Fatalf("Findings(5) = %+v, want newest five f024..f020", got)
}
}
+169 -18
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"regexp"
@@ -260,16 +261,20 @@ func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
return nil, err
}
defer storeRoot.Close()
path := filepath.Join(taskRel, "state", "findings.jsonl")
// Bounded requests (the newest-N views) read only the file tail; limit 0
// keeps the full scan because accepted-evidence lookups need every entry.
lines, err := tailJSONLLines(storeRoot, path, limit)
if err != nil {
return nil, err
}
var findings []Finding
if err := readJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), func(data []byte) error {
for _, line := range lines {
var f Finding
if err := json.Unmarshal(data, &f); err != nil {
return err
if err := json.Unmarshal(line, &f); err != nil {
return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
}
findings = append(findings, f)
return nil
}); err != nil {
return nil, err
}
for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 {
findings[i], findings[j] = findings[j], findings[i]
@@ -307,16 +312,21 @@ func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) {
return nil, err
}
defer storeRoot.Close()
path := filepath.Join(taskRel, "logs", "heartbeat.jsonl")
// Bounded requests only need the file tail; heartbeat logs grow one line
// per turn for the life of a task, so a full scan per read is a per-turn
// cost that keeps rising.
lines, err := tailJSONLLines(storeRoot, path, limit)
if err != nil {
return nil, err
}
var heartbeats []Heartbeat
if err := readJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), func(data []byte) error {
for _, line := range lines {
var h Heartbeat
if err := json.Unmarshal(data, &h); err != nil {
return err
if err := json.Unmarshal(line, &h); err != nil {
return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
}
heartbeats = append(heartbeats, h)
return nil
}); err != nil {
return nil, err
}
if limit > 0 && len(heartbeats) > limit {
heartbeats = heartbeats[len(heartbeats)-limit:]
@@ -366,15 +376,20 @@ func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) {
if err != nil {
return nil, err
}
fp := slugify(d.Summary)
fp := directionFingerprint(d.Summary)
repeated := false
for i := range directions {
if directions[i].Fingerprint == fp {
repeated = true
directions[i].Count++
directions[i].LastSeenIteration = progress.Iteration
break
// Legacy entries carry pre-hash fingerprints; recompute from the
// stored summary so repeats recorded by older versions still match,
// then migrate the entry in place.
if directions[i].Fingerprint != fp && directionFingerprint(directions[i].Summary) != fp {
continue
}
repeated = true
directions[i].Fingerprint = fp
directions[i].Count++
directions[i].LastSeenIteration = progress.Iteration
break
}
if !repeated {
directions = append(directions, DirectionTried{
@@ -619,6 +634,87 @@ func readJSONL(root *os.Root, path string, each func([]byte) error) error {
return nil
}
// tailJSONLLines returns the last limit non-empty lines of a JSONL file in
// file order, reading backward in fixed-size chunks so per-turn readers do not
// rescan an append-only log that grows for the life of a task. limit <= 0
// reads the whole file (legacy unbounded behavior).
func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
if limit <= 0 {
var lines [][]byte
if err := readJSONL(root, path, func(data []byte) error {
line := make([]byte, len(data))
copy(line, data)
lines = append(lines, line)
return nil
}); err != nil {
return nil, err
}
return lines, nil
}
f, err := root.Open(path)
if err != nil {
return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("autoresearch: stat %s: %w", path, err)
}
const chunkSize = 64 * 1024
var (
buf []byte
off = info.Size()
)
for off > 0 {
readLen := int64(chunkSize)
if off < readLen {
readLen = off
}
off -= readLen
chunk := make([]byte, readLen)
if _, err := f.ReadAt(chunk, off); err != nil {
return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
}
buf = append(chunk, buf...)
// Stop once the buffered tail holds enough complete lines. Count
// newline-separated non-empty segments after the first newline (the
// first segment may be a partial line unless we reached offset 0).
if countCompleteTailLines(buf, off == 0) > limit {
break
}
}
segments := strings.Split(string(buf), "\n")
if off > 0 && len(segments) > 0 {
segments = segments[1:] // drop the leading partial line
}
var lines [][]byte
for _, seg := range segments {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
lines = append(lines, []byte(seg))
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return lines, nil
}
func countCompleteTailLines(buf []byte, atStart bool) int {
segments := strings.Split(string(buf), "\n")
if !atStart && len(segments) > 0 {
segments = segments[1:]
}
count := 0
for _, seg := range segments {
if strings.TrimSpace(seg) != "" {
count++
}
}
return count
}
func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) {
path := filepath.Join(taskRel, "state", "directions_tried.json")
data, err := root.ReadFile(path)
@@ -712,3 +808,58 @@ func slugify(s string) string {
}
return slug
}
// directionFingerprint identifies a direction for repeat detection. slugify
// alone truncates to 56 chars and drops non-ASCII runes, so two different
// directions sharing a long ASCII prefix (or differing only in CJK text)
// collapsed to one fingerprint and wrongly inflated StaleCount/PivotCount on
// long tasks. Append a hash of the full normalized text when slugify lost
// distinguishing content (truncation or non-ASCII letters/digits); keep the
// bare slug otherwise so fingerprints recorded by older versions still match,
// and punctuation-only differences stay fuzzy-matched as before.
func directionFingerprint(summary string) string {
slug := slugify(summary)
if slug == slugifyUnbounded(summary) && !containsNonASCIIWord(summary) {
return slug
}
normalized := strings.Join(strings.Fields(strings.ToLower(summary)), " ")
h := fnv.New32a()
_, _ = h.Write([]byte(normalized))
return fmt.Sprintf("%s-%08x", slug, h.Sum32())
}
// slugifyUnbounded matches slugify without the 56-char cap, used to detect
// whether truncation dropped content.
func slugifyUnbounded(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
lastDash := false
for _, r := range s {
switch {
case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
b.WriteRune(r)
lastDash = false
default:
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
slug := strings.Trim(b.String(), "-")
if slug == "" {
return "task"
}
return slug
}
// containsNonASCIIWord reports whether s carries letters/digits that slugify
// discards entirely (e.g. CJK), meaning distinct summaries could share a slug.
func containsNonASCIIWord(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
return true
}
}
return false
}
+49 -23
View File
@@ -44,6 +44,7 @@ import (
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/secrets"
"reasonix/internal/skill"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
@@ -146,6 +147,13 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
if err != nil {
return nil, err
}
// Arm the credential-protection layers from the user-global [secrets]
// section before any tool, hook, or plugin subprocess can spawn. Package
// globals are correct here because [secrets] is user-global (project
// reasonix.toml cannot override it), so concurrent workspaces agree.
secrets.SetRedactToolOutput(cfg.SecretsRedactToolOutput())
secrets.SetFilterSubprocessEnv(cfg.Secrets.FilterSubprocessEnv)
secrets.SetProtectSensitiveFiles(cfg.Secrets.ProtectSensitiveFiles)
modelName := opts.Model
if modelName == "" {
modelName = cfg.DefaultModel
@@ -182,21 +190,25 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
ReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands,
}
if ignored := planModePolicy.IgnoredAllowedTools(); len(ignored) > 0 {
detail := fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash. For shell exploration, declare concrete read-only prefixes in plan_mode_read_only_commands (for example \"gh issue view\"); use read_only_task/read_only_skill instead of task/run_skill while planning.", strings.Join(ignored, ", "))
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash. For shell exploration, declare concrete read-only prefixes in plan_mode_read_only_commands (for example \"gh issue view\"); use read_only_task/read_only_skill instead of task/run_skill while planning.", strings.Join(ignored, ", ")),
Kind: event.Notice,
Level: event.LevelWarn,
Text: "Some plan-mode tool settings were ignored.",
Detail: detail,
})
}
if ignored := planModePolicy.IgnoredReadOnlyCommands(); len(ignored) > 0 {
detail := fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters, overly broad prefixes, malformed prefixes, or writer-capable command verbs", strings.Join(ignored, ", "))
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters, overly broad prefixes, malformed prefixes, or writer-capable command verbs", strings.Join(ignored, ", ")),
Kind: event.Notice,
Level: event.LevelWarn,
Text: "Some plan-mode command settings were ignored.",
Detail: detail,
})
}
if migErr != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "config migration from ~/.reasonix failed: " + migErr.Error()})
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Config migration did not complete.", Detail: "config migration from ~/.reasonix failed: " + migErr.Error()})
} else if migrated != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: migrated.Notice()})
}
@@ -207,7 +219,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
// (RequireKey is false so the UI stays reachable) and then fail silently on the
// first request, showing as an empty/dead model. Surface the cause up front.
if !opts.RequireKey && entry.RequiresAPIKey() && entry.APIKey() == "" {
sink.Emit(event.Event{Kind: event.Notice, Text: fmt.Sprintf("model %q is selected but its API key %s is not set — requests will fail until you set it", modelName, entry.APIKeyEnv)})
sink.Emit(event.Event{Kind: event.Notice, Text: "Selected model is missing its API key.", Detail: fmt.Sprintf("model %q is selected but its API key %s is not set — requests will fail until you set it", modelName, entry.APIKeyEnv)})
}
jm := jobs.NewManager(sink, jobs.WithStalledWarningAfter(time.Duration(cfg.BackgroundJobStalledWarningSeconds())*time.Second))
sessionDir := opts.SessionDir
@@ -310,6 +322,10 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
reg := tool.NewRegistry()
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: cfg.WriteRootsForRoot(root), ForbidReadRoots: cfg.ForbidReadRootsForRoot(root), Network: cfg.Sandbox.Network}
bashSpec.Shell = shell
// The session-data guard blocks agent writes into Reasonix's own session
// stores (they race the app's saves and surface as conflict-copy loops);
// explicit allow_write entries stay a sanctioned escape hatch.
sessionGuard := builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots())
if bashSpec.Mode == "enforce" && !sandbox.Available() {
fmt.Fprintln(stderr, "warning: "+sandbox.UnavailableMessage())
}
@@ -323,7 +339,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
enabledBuiltins = tokenEconomyBuiltins(enabledBuiltins)
}
readPathResolver := builtin.NewPathResolver()
addBuiltins(reg, enabledBuiltins, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec, cfg.ForbidReadRootsForRoot(root), readPathResolver)
addBuiltins(reg, enabledBuiltins, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec, cfg.ForbidReadRootsForRoot(root), readPathResolver, sessionGuard)
// Use the caller-supplied shared host when set, so controllers for the same
// workspace root reuse running MCP processes (e.g. one CodeGraph daemon
// instead of one per tab). Otherwise construct a private host per controller.
@@ -431,7 +447,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
}
}
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
Text: fmt.Sprintf("mcp %s: %v", s.Name, err)})
Text: "An MCP server failed to start.", Detail: fmt.Sprintf("mcp %s: %v", s.Name, err)})
continue
}
for _, t := range tools {
@@ -448,8 +464,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
// controller's session-scoped PluginCtx — so the auxiliary surfaces
// keep streaming in after Start returns without holding up the agent.
go host.StartPhaseB(ctx, sink)
if text, ok := MCPStartupNotice(host.Failures()); ok {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
if text, detail, ok := MCPStartupNotice(host.Failures()); ok {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text, Detail: detail})
}
}
}
@@ -1134,12 +1150,12 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
ge, ok := cfg.ResolveModel(guardianModel)
if !ok {
slog.Warn("guardian model is not a configured provider — guardian disabled", "model", guardianModel)
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf("guardian_model %q not found — guardian disabled", guardianModel)})
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Guardian was disabled because its model was not found.", Detail: fmt.Sprintf("guardian_model %q not found — guardian disabled", guardianModel)})
} else {
pProv, err := NewProviderWithProxy(ge, proxySpec)
if err != nil {
slog.Warn("guardian provider construction failed — guardian disabled", "model", guardianModel, "err", err)
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf("guardian construction failed: %v — guardian disabled", err)})
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Guardian was disabled because it could not start.", Detail: fmt.Sprintf("guardian construction failed: %v — guardian disabled", err)})
} else {
guardianReg := agent.FilterReadOnlyRegistry(reg, agent.SubagentMetaTools()...)
ctrlOpts.Guardian = guardian.NewSession(pProv, guardianReg, guardian.PolicyPrompt(), guardianModel, cfg.Agent.GuardianTemperature, ge.Price, sink)
@@ -1479,12 +1495,14 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
// the listed directories.
// When workDir is non-empty, tools resolve relative paths against it instead of
// the process cwd, enabling concurrent multi-project sessions.
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots []string, readPathResolver *builtin.PathResolver) {
// sessionGuard blocks writer-tool targets inside Reasonix's own session stores
// and makes bash warn when a command references them.
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots []string, readPathResolver *builtin.PathResolver, sessionGuard builtin.SessionDataGuard) {
// If a workspace directory is set, use workspace-bound tools that resolve
// paths relative to that directory. Otherwise fall back to the process-cwd
// compile-time builtins.
if workDir != "" {
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver}
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver, SessionGuard: sessionGuard}
for _, t := range ws.Tools(enabled...) {
reg.Add(t)
}
@@ -1508,8 +1526,8 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand
// preserved on replace): file-writers bound to the workspace, read tools
// bound to forbid-read roots, bash to the OS sandbox, web_fetch to the proxy.
// Only replace tools actually enabled/present.
confined := append(builtin.ConfineWriters(writeRoots),
builtin.ConfineBash(bashSpec, bashTimeout),
confined := append(builtin.ConfineWriters(writeRoots, sessionGuard),
builtin.ConfineBash(bashSpec, sessionGuard, bashTimeout),
builtin.ConfineSearch(searchSpec, bashSpec, forbidReadRoots),
builtin.ConfineWebFetch(proxySpec))
confined = append(confined, builtin.ConfineReaders(forbidReadRoots)...)
@@ -1733,23 +1751,31 @@ func autoShellPrefer(prefer string) bool {
// MCPStartupNotice formats the warning shown when configured MCP servers failed
// to connect, naming the first few; ok is false when none failed.
func MCPStartupNotice(failures []plugin.Failure) (text string, ok bool) {
func MCPStartupNotice(failures []plugin.Failure) (text, detail string, ok bool) {
if len(failures) == 0 {
return "", false
return "", "", false
}
names := make([]string, 0, min(len(failures), 3))
details := make([]string, 0, len(failures))
for i, f := range failures {
if i >= 3 {
break
continue
}
names = append(names, f.Name)
}
for _, f := range failures {
line := f.Name
if strings.TrimSpace(f.Error) != "" {
line += ": " + strings.TrimSpace(f.Error)
}
details = append(details, line)
}
more := ""
if len(failures) > len(names) {
more = fmt.Sprintf(" (+%d more)", len(failures)-len(names))
}
return fmt.Sprintf("%d MCP server(s) failed to start: %s%s — run /mcp for details",
len(failures), strings.Join(names, ", "), more), true
return "Some MCP servers failed to start; run /mcp for details.", fmt.Sprintf("%d MCP server(s) failed to start: %s%s\n%s",
len(failures), strings.Join(names, ", "), more, strings.Join(details, "\n")), true
}
// LSPSpecs returns the language → server map: the built-in defaults overlaid with
+15 -9
View File
@@ -1985,12 +1985,15 @@ model = "x"
defer ctrl.Close()
for _, notice := range notices {
if notice.Level == event.LevelWarn && strings.Contains(notice.Text, "plan_mode_allowed_tools") && strings.Contains(notice.Text, "bash") {
if strings.Contains(notice.Text, "custom_reader") {
t.Fatalf("warning should name ignored entries only, got %q", notice.Text)
if notice.Level == event.LevelWarn && strings.Contains(notice.Detail, "plan_mode_allowed_tools") && strings.Contains(notice.Detail, "bash") {
if notice.Text != "Some plan-mode tool settings were ignored." {
t.Fatalf("warning text = %q, want short user-facing text", notice.Text)
}
if !strings.Contains(notice.Text, "plan_mode_read_only_commands") || !strings.Contains(notice.Text, "read_only_task/read_only_skill") {
t.Fatalf("warning should suggest plan-mode migration paths, got %q", notice.Text)
if strings.Contains(notice.Detail, "custom_reader") {
t.Fatalf("warning should name ignored entries only, got %q", notice.Detail)
}
if !strings.Contains(notice.Detail, "plan_mode_read_only_commands") || !strings.Contains(notice.Detail, "read_only_task/read_only_skill") {
t.Fatalf("warning should suggest plan-mode migration paths, got %q", notice.Detail)
}
return
}
@@ -2033,10 +2036,13 @@ model = "x"
defer ctrl.Close()
for _, notice := range notices {
if notice.Level == event.LevelWarn && strings.Contains(notice.Text, "plan_mode_read_only_commands") && strings.Contains(notice.Text, "bash") {
ignoredList := strings.TrimSpace(strings.SplitN(strings.TrimPrefix(notice.Text, "plan_mode_read_only_commands ignored unsafe entries:"), ";", 2)[0])
if notice.Level == event.LevelWarn && strings.Contains(notice.Detail, "plan_mode_read_only_commands") && strings.Contains(notice.Detail, "bash") {
if notice.Text != "Some plan-mode command settings were ignored." {
t.Fatalf("warning text = %q, want short user-facing text", notice.Text)
}
ignoredList := strings.TrimSpace(strings.SplitN(strings.TrimPrefix(notice.Detail, "plan_mode_read_only_commands ignored unsafe entries:"), ";", 2)[0])
if ignoredList != "bash" {
t.Fatalf("warning should name ignored command prefixes only, got %q from %q", ignoredList, notice.Text)
t.Fatalf("warning should name ignored command prefixes only, got %q from %q", ignoredList, notice.Detail)
}
return
}
@@ -2158,7 +2164,7 @@ model = "x"
func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
reg := tool.NewRegistry()
var stderr bytes.Buffer
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil)
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil, builtin.SessionDataGuard{})
for _, name := range []string{
"todo_write",
"complete_step",
+3 -3
View File
@@ -83,11 +83,11 @@ model = "m"
api_key_env = "`+keyEnv+`"
`)
var notices []string
var notices []event.Event
ctrl, err := Build(context.Background(), Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
notices = append(notices, e)
}
}),
})
@@ -98,7 +98,7 @@ api_key_env = "`+keyEnv+`"
found := false
for _, n := range notices {
if strings.Contains(n, keyEnv) {
if n.Text == "Selected model is missing its API key." && strings.Contains(n.Detail, keyEnv) {
found = true
}
}
+2
View File
@@ -17,6 +17,7 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/secrets"
)
const (
@@ -616,6 +617,7 @@ func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProje
}
args = append(args, roots...)
cmd := exec.CommandContext(ctx, rg, args...)
cmd.Env = secrets.ProcessEnv()
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
+7 -6
View File
@@ -190,12 +190,13 @@ func (f *acpFactory) SessionConfigState(_ context.Context, p acp.SessionConfigSt
func acpBuiltinTools(cfg *config.Config, cwd string, writeRoots []string) []tool.Tool {
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: writeRoots, Network: cfg.Sandbox.Network}
ws := builtin.Workspace{
Dir: cwd,
WriteRoots: writeRoots,
Bash: bashSpec,
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
ProxySpec: cfg.NetworkProxySpec(),
Dir: cwd,
WriteRoots: writeRoots,
Bash: bashSpec,
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
ProxySpec: cfg.NetworkProxySpec(),
SessionGuard: builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots()),
}
return ws.Tools(cfg.Tools.Enabled...)
}
+6
View File
@@ -12,6 +12,7 @@ import (
func (m *chatTUI) showBranchTree() {
branches, err := m.ctrl.Branches()
m.followSessionLease()
if err != nil {
m.notice("tree: " + err.Error())
return
@@ -71,6 +72,7 @@ func (m *chatTUI) runBranchCommand(input string) {
return
} else if fromTurn {
if _, err := m.ctrl.ForkNamed(n-1, name); err != nil {
m.followSessionLease()
return
}
m.followSessionLease()
@@ -78,6 +80,7 @@ func (m *chatTUI) runBranchCommand(input string) {
return
} else {
if _, err := m.ctrl.Branch(name); err != nil {
m.followSessionLease()
return
}
m.followSessionLease()
@@ -96,12 +99,15 @@ func (m *chatTUI) runSwitchCommand(input string) {
// failures fall through to SwitchBranch, which reports them as before.
if m.leases != nil {
if branches, err := m.ctrl.Branches(); err == nil {
m.followSessionLease()
if match, err := control.ResolveBranchRef(branches, ref); err == nil {
if err := m.rebindSessionLease(match.Path); err != nil {
m.notice("switch: " + sessionLeaseHeldNotice(err))
return
}
}
} else {
m.followSessionLease()
}
}
if _, err := m.ctrl.SwitchBranch(ref); err != nil {
+24 -2
View File
@@ -365,6 +365,11 @@ const resetMouseTracking = ansi.ResetModeMouseX10 +
// snapshots on success.
type compactDoneMsg struct{ err error }
// tuiShutdownMsg asks the live TUI model to persist its current controller and
// quit. It is injected from the signal handler so shutdown does not snapshot a
// stale controller captured before an in-TUI rebuild.
type tuiShutdownMsg struct{}
// elapsedTickMsg fires once a second while a turn runs, driving the "thinking
// Ns" counter in the status line.
type elapsedTickMsg struct{}
@@ -401,14 +406,20 @@ func (m chatTUI) runStatusline() tea.Cmd {
return func() tea.Msg { return statuslineMsg{out: runStatuslineCmd(cmd, string(payload))} }
}
const statuslineCommandTimeout = 2 * time.Second
// runStatuslineCmd runs a status-line command with the JSON context on stdin and
// returns its first stdout line (status lines are a single row). A tight timeout
// keeps a slow script from stalling the UI; any failure collapses to "".
func runStatuslineCmd(cmd, stdinPayload string) string {
return runStatuslineCmdWithTimeout(cmd, stdinPayload, statuslineCommandTimeout)
}
func runStatuslineCmdWithTimeout(cmd, stdinPayload string, timeout time.Duration) string {
res := hook.DefaultSpawner(context.Background(), hook.SpawnInput{
Command: cmd,
Stdin: stdinPayload + "\n",
Timeout: 2 * time.Second,
Timeout: timeout,
})
out := strings.TrimSpace(res.Stdout)
if i := strings.IndexByte(out, '\n'); i >= 0 {
@@ -1358,14 +1369,25 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashCompactFailed, msg.err))
} else {
_ = m.ctrl.Snapshot()
m.followSessionLease()
}
case tuiShutdownMsg:
if m.ctrl != nil {
_ = m.ctrl.Snapshot()
m.followSessionLease()
}
return m, tea.Quit
case modelSwitchMsg:
m.modelSwitchPending = false
m.pendingModelSwitch = nil
if msg.err != nil {
m.notice("model: " + msg.err.Error())
// Build failed — no old controller to retire.
// Build failed — no old controller to retire. The kept controller
// may still have been retargeted to a recovery branch by the
// pre-switch snapshot, so the lease must follow it.
m.followSessionLease()
} else {
m.ctrl = msg.ctrl
m.label = msg.label
+1 -2
View File
@@ -796,8 +796,7 @@ func chatREPL(args []string) int {
signal.Notify(hangup, syscall.SIGHUP, syscall.SIGTERM)
go func() {
for range hangup {
_ = ctrl.Snapshot()
p.Quit()
p.Send(tuiShutdownMsg{})
}
}()
final, runErr := p.Run()
+3
View File
@@ -262,6 +262,9 @@ func (m *chatTUI) branchArgItems(val string) ([]compItem, int, bool) {
return nil, from, true
}
branches, err := m.ctrl.Branches()
// Branches snapshots first, which can retarget the controller to a
// recovery branch; keep the lease on whatever the controller now owns.
m.followSessionLease()
if err != nil {
return nil, from, true
}
+1 -1
View File
@@ -491,7 +491,7 @@ func TestSlashArgCompletionMemoryV5(t *testing.T) {
if !m.completion.active || m.completion.kind != compSlashArg {
t.Fatalf("/memory-v5 should open arg completion: %+v", m.completion)
}
for _, want := range []string{"status", "off", "observe", "compact", "on"} {
for _, want := range []string{"status", "off", "observe", "compact", "on", "learnings"} {
if !hasLabel(m.completion.items, want) {
t.Fatalf("/memory-v5 completion missing %q: %v", want, labels(m.completion.items))
}
+131
View File
@@ -5,11 +5,18 @@ import (
"flag"
"fmt"
"os"
"strings"
"reasonix/internal/doctor"
)
func doctorCommand(args []string, version string) int {
if len(args) > 0 && args[0] == "session" {
return doctorSessionCommand(args[1:], version)
}
if len(args) > 0 && args[0] == "redact-sessions" {
return doctorRedactSessionsCommand(args[1:])
}
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print diagnostics as JSON")
if err := fs.Parse(args); err != nil {
@@ -29,3 +36,127 @@ func doctorCommand(args []string, version string) int {
fmt.Print(doctor.RenderText(report))
return 0
}
type stringListFlag []string
func (f *stringListFlag) String() string {
if f == nil {
return ""
}
return strings.Join(*f, string(os.PathListSeparator))
}
func (f *stringListFlag) Set(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("empty path")
}
*f = append(*f, value)
return nil
}
func doctorRedactSessionsCommand(args []string) int {
fs := flag.NewFlagSet("doctor redact-sessions", flag.ContinueOnError)
var dirs stringListFlag
dryRun := fs.Bool("dry-run", false, "show how many session files would be redacted without writing")
jsonOut := fs.Bool("json", false, "print result as JSON")
fs.Var(&dirs, "dir", "session directory to scan; repeat to scan multiple directories")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 0 {
fmt.Fprintln(os.Stderr, "usage: reasonix doctor redact-sessions [--dry-run] [--json] [--dir PATH]")
return 2
}
res := doctor.RedactSessions(doctor.RedactSessionsOptions{
Dirs: []string(dirs),
DryRun: *dryRun,
})
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(res); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
} else {
action := "redacted"
if *dryRun {
action = "would redact"
}
fmt.Fprintf(os.Stdout, "session secret cleanup %s %d/%d files", action, res.FilesChanged, res.FilesScanned)
if res.FilesSkipped > 0 {
fmt.Fprintf(os.Stdout, " (%d skipped: active lease held)", res.FilesSkipped)
}
fmt.Fprintln(os.Stdout)
}
for _, msg := range res.Errors {
fmt.Fprintln(os.Stderr, "warning:", msg)
}
if len(res.Errors) > 0 {
return 1
}
return 0
}
func doctorSessionCommand(args []string, version string) int {
ref := ""
outPath := ""
for i := 0; i < len(args); i++ {
arg := args[i]
switch arg {
case "-h", "--help":
fmt.Fprintln(os.Stdout, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
fmt.Fprintln(os.Stdout, "")
fmt.Fprintln(os.Stdout, "Bundles the session transcript, persistence sidecars, conflict diagnostics,")
fmt.Fprintln(os.Stdout, "and the recovery parent chain into a zip for support. Unlike `reasonix doctor`,")
fmt.Fprintln(os.Stdout, "bundled transcripts are NOT redacted; share only with a trusted support channel.")
return 0
case "--zip":
// The subcommand currently writes a zip by default. Keep --zip as an
// explicit, script-friendly marker so support replies can say exactly
// what to run.
case "--out":
i++
if i >= len(args) {
fmt.Fprintln(os.Stderr, "error: --out requires a path")
return 2
}
outPath = args[i]
default:
if v, ok := strings.CutPrefix(arg, "--out="); ok {
if v == "" {
fmt.Fprintln(os.Stderr, "error: --out requires a path")
return 2
}
outPath = v
continue
}
if strings.HasPrefix(arg, "-") {
fmt.Fprintf(os.Stderr, "error: unknown doctor session flag %s\n", arg)
return 2
}
if ref != "" {
fmt.Fprintln(os.Stderr, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
return 2
}
ref = arg
}
}
if ref == "" {
fmt.Fprintln(os.Stderr, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
return 2
}
result, err := doctor.WriteSessionBundle(doctor.SessionBundleOptions{
Version: version,
SessionRef: ref,
OutputPath: outPath,
})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println(result.Path)
fmt.Fprintln(os.Stderr, "note: the bundle contains full session transcripts without redaction; share it only with a trusted support channel")
return 0
}
+74
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -35,6 +36,30 @@ func TestRunDispatchesDoctor(t *testing.T) {
}
}
func TestDoctorSessionCommandWritesBundle(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
sessionDir := filepath.Join(home, "sessions")
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sessionDir, "abc.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(t.TempDir(), "abc-diag.zip")
out := captureStdout(t, func() {
if rc := doctorCommand([]string{"session", "abc", "--zip", "--out", outPath}, "test-version"); rc != 0 {
t.Fatalf("doctor session rc = %d, want 0", rc)
}
})
if strings.TrimSpace(out) != outPath {
t.Fatalf("doctor session output = %q, want %q", strings.TrimSpace(out), outPath)
}
if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
t.Fatalf("bundle stat = %v, %v", info, err)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
@@ -55,3 +80,52 @@ func captureStdout(t *testing.T, fn func()) string {
}
return string(data)
}
func TestDoctorSessionCommandOutEqualsForm(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
sessionDir := filepath.Join(home, "sessions")
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sessionDir, "abc.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(t.TempDir(), "abc-diag.zip")
out := captureStdout(t, func() {
if rc := doctorCommand([]string{"session", "abc", "--out=" + outPath}, "test-version"); rc != 0 {
t.Fatalf("doctor session rc = %d, want 0", rc)
}
})
if strings.TrimSpace(out) != outPath {
t.Fatalf("doctor session output = %q, want %q", strings.TrimSpace(out), outPath)
}
if rc := doctorCommand([]string{"session", "abc", "--out="}, "test-version"); rc != 2 {
t.Fatalf("empty --out= rc = %d, want usage error 2", rc)
}
}
func TestDoctorRedactSessionsCommand(t *testing.T) {
dir := t.TempDir()
const secret = "sk-real-secret-value-123456"
path := filepath.Join(dir, "abc.jsonl")
if err := os.WriteFile(path, []byte(`{"role":"tool","content":"DEEPSEEK_API_KEY=`+secret+`"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
out := captureStdout(t, func() {
if rc := doctorCommand([]string{"redact-sessions", "--dir", dir}, "test-version"); rc != 0 {
t.Fatalf("doctor redact-sessions rc = %d, want 0", rc)
}
})
if !strings.Contains(out, "session secret cleanup redacted 1/1 files") {
t.Fatalf("unexpected output:\n%s", out)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), secret) {
t.Fatalf("doctor redact-sessions leaked secret:\n%s", data)
}
}
+8
View File
@@ -90,6 +90,14 @@ func (m *chatTUI) runEffortCommand(input string) tea.Cmd {
// controller back to the original file, re-conflicting on every later save.
carried := m.ctrl.History()
prevPath := m.ctrl.SessionPath()
// Move the lease before the rebuilt controller binds prevPath for writing
// (AdoptHistory resumes there): after a snapshot retarget the lease still
// guards the old path, and the async build must not open an unguarded
// writer on the recovery branch.
if err := m.rebindSessionLease(prevPath); err != nil {
m.notice("effort: " + sessionLeaseHeldNotice(err))
return nil
}
oldCtrl := m.ctrl
build := m.buildController
m.modelSwitchPending = true
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more