Compare commits

...
400 Commits
Author SHA1 Message Date
SivanCola 295b3f71ca Merge pull request #7536 from esengine/release-notes/v1.19.7
docs(release): prepare v1.19.7 notes / 准备 v1.19.7 中英更新日志
2026-08-05 09:12:14 +08:00
SivanCola 264071dc9d docs(release): refresh v1.19.7 after late merges
Problem:
The first reviewed release candidate was superseded by two merges on main-v2, and the regenerated draft again narrowed the release surface to Desktop while omitting the DeepSeek web-search privacy and billing risk.

Root cause:
The generated draft only reflected selected user-facing categories and did not preserve the reviewed metadata corrections from the prior release-notes PR.

Fix:
Record PRs #7531 and #7532, restore the full product surface inventory, and document the default-on DeepSeek server-search opt-out and billing implications.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- bilingual render for v1.19.7
- git diff --check
- jq empty release-notes/releases.json
2026-08-05 09:11:26 +08:00
github-actions[bot] 685694dcdd docs(release): prepare v1.19.7 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-05 01:09:54 +00:00
YHH 93243ab14b feat(agent): store-P2 write-authority probe for unleased session saves (#7532)
Observe-first increment toward lease-holder-is-single-writer: one deduped structured warning per unleased save path; no behavior change. Store-P2 groundwork for the snapshot-conflict cluster.
2026-08-05 01:03:56 +00:00
YHH 467a46f80b fix(bot): honor persisted session_mappings when resolving chat sessions (#7531)
sessionProfileForMessage now consumes the persisted chat→session binding (soft, degradable) instead of only the in-memory /attach override; degraded states stay stable per turn. Fixes #6917, #6934
2026-08-05 00:56:45 +00:00
SivanCola dfd8f7774b Merge pull request #7530 from esengine/release-notes/v1.19.7
docs(release): prepare v1.19.7 notes / 准备 v1.19.7 中英更新日志
2026-08-05 08:50:15 +08:00
SivanCola 26a7ba89db docs(release): correct v1.19.7 scope and risks
Problem:
The generated catalog listed Desktop as the only affected surface and omitted the default-on DeepSeek web-search privacy and billing caveat.

Root cause:
The release-note generator retained its example surface and did not promote the supported opt-out guidance into the risk section.

Fix:
Record every affected product surface and add bilingual, PR-grounded web-search privacy, billing, and opt-out guidance.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- bilingual render review
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-05 08:49:12 +08:00
github-actions[bot] e7248c7aa9 docs(release): prepare v1.19.7 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-05 00:46:49 +00:00
SivanCola 2c54501b5e fix(agent): preserve parallel subagent results (#7375)
Problem: Long parallel_tasks and fleet answers could exceed the single-tool output limit, combining and truncating independent child reports.

Root cause: Both aggregators concatenated every full child answer before the global output guard applied head/tail truncation.

Fix: Persist each child transcript, return bounded fair previews with stable references, and add a conversation-scoped read_subagent_result pager with lineage and workspace checks.

Verification: go test ./... -count=1; go test -race ./internal/agent -count=1; ./scripts/cache-guard.sh; git diff --check.
2026-08-05 02:43:45 +08:00
SivanCola 8f6015118e Move desktop style to the top of general settings (#7515)
Problem: The desktop style selector is buried below language and currency even though it changes the application's primary layout.

Root cause: General settings render the selector as the third field.

Fix: Render the existing desktop style field first without changing its behavior, and add a DOM ordering regression assertion.

Verification:
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm exec eslint src/components/SettingsPanel.tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm typecheck
- git diff --check
2026-08-05 02:09:30 +08:00
SivanCola d643b37b9b feat(deepseek): add server-side web search controls / 添加服务端网页搜索控制 (#7466)
* feat(deepseek): add server-side web search controls

* Harden DeepSeek web search defaults and replay

Problem:
- Existing official DeepSeek configurations did not inherit web search defaults.
- Responses API search output was discarded between stateless turns.
- Endpoint gating and billing copy were broader than the documented contracts.

Root cause:
- web_search used a two-state boolean, endpoint checks matched hosts loosely, and the shared stream/session model had no slot for provider-issued Responses items.

Fix:
- Resolve web_search as a persisted tri-state on exact official endpoints with explicit opt-out.
- Persist validated completed web_search_call items and replay them only on official DeepSeek Responses.
- Carry search replay items across the remote Broker protocol with bounded JSON validation.
- Align endpoint gating and cost copy with the official contract and add regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/provider/... ./internal/config ./internal/agent ./internal/remote/protocol ./internal/remote/broker
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend lint:hooks
- ./scripts/cache-guard.sh
- go vet ./...

* fix(desktop): account for Traditional Chinese search copy

Problem:
- The Desktop and desktop-windows CI jobs failed because the Traditional Chinese locale compressed to 52.1 KiB against a 52 KiB budget.

Root cause:
- The new server-side web search controls intentionally add localized capability, privacy, billing, protocol, and model guidance, while the zh-TW chunk had no remaining budget headroom.

Fix:
- Give only the zh-TW on-demand locale a 53 KiB budget while retaining the 52 KiB Simplified Chinese limit and every existing initial bundle limit.

Verification:
- pnpm build
- pnpm test:all
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/provider/... ./internal/config ./internal/agent ./internal/remote/protocol ./internal/remote/broker
- ./scripts/cache-guard.sh

* fix(desktop): simplify local file URL normalization

Problem:
- The Desktop CI job failed staticcheck S1017 on the latest main-v2 integration tree.

Root cause:
- normalizeLocalOpenPath checked for the file URL prefix before calling strings.TrimPrefix, even though TrimPrefix is already a safe no-op when the prefix is absent.

Fix:
- Normalize the optional file URL prefix unconditionally while preserving plain absolute path behavior.

Verification:
- go test . -run 'TestNormalizeLocalOpenPath|TestOpenLocalPath' -count=20
- go vet ./...
- golangci-lint run --timeout=5m

* test(cli): make unknown slash routing deterministic

Problem:
- Coverage and Ubuntu CI intermittently failed TestUnknownSlashCommandDoesNotStartTurn.

Root cause:
- Since #5756, unknown slash input intentionally starts an ordinary model turn with a typo notice, but the stale test asserted that no turn starts. Because controller submission is asynchronous, the assertion raced the runner and passed or failed depending on scheduling.

Fix:
- Assert the current routing contract, wait for TurnDone, and verify exactly one ordinary turn plus the visible unknown-command notice.

Verification:
- go test ./internal/cli -run TestUnknownSlashCommandStartsOrdinaryTurnWithNotice -count=100
- go test -race ./internal/cli -run TestUnknownSlashCommandStartsOrdinaryTurnWithNotice -count=20
- go test ./internal/cli -count=1
2026-08-05 02:00:37 +08:00
dependabot[bot] 283cd58fea chore(deps): bump the actions group across 1 directory with 6 updates (#7483)
Bumps the actions group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-go](https://github.com/actions/setup-go) | `6` | `7` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6` | `6.0.9` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6` | `7` |
| [github/codeql-action](https://github.com/github/codeql-action) | `4` | `4.37.4` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6` | `7` |
| [actions/labeler](https://github.com/actions/labeler) | `6` | `7` |



Updates `actions/setup-go` from 6 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

Updates `pnpm/action-setup` from 6 to 6.0.9
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6...v6.0.9)

Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

Updates `github/codeql-action` from 4 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

Updates `actions/setup-python` from 6 to 7
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

Updates `actions/labeler` from 6 to 7
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 00:53:31 +08:00
Qiaochu Hu f06bd1da11 fix(openai): recover half-open stream stalls and tolerate empty data: lines (#7374)
Two stream-robustness fixes in the OpenAI-compatible provider (DeepSeek,
MiMo, MiniMax-M3, and any OpenAI-compatible gateway).

1. A watchdog stall bypassed stream recovery. The idle watchdog exists to
   turn a half-open TCP drop — a local proxy (v2rayN/sing-box) that silently
   drops the long-lived SSE connection during a reasoner's first-token gap,
   with no FIN/RST — into a recoverable error. It detected the stall but
   returned a plain "stream stalled" error with no sentinel, while the
   clean-FIN idle-close path wraps io.ErrUnexpectedEOF. streamWithReconnect
   gates reconnect/StreamInterruptedError on provider.IsConnReset, which the
   plain error failed, so the stall hard-failed the turn instead of replaying
   — even though a clean idle-close on the same connection recovered. Wrap
   the stall error with io.ErrUnexpectedEOF so the half-open stall is treated
   identically to the clean-FIN drop.

2. An empty or heartbeat `data:` line fatally aborted the whole stream.
   readStream unmarshaled every data: payload with no empty guard, so
   json.Unmarshal("") returned "unexpected end of JSON input" and ended the
   turn with a confusing decode error. The Anthropic and Responses transports
   both tolerate empty data: lines; the OpenAI transport was the outlier. Skip
   empty payloads like Anthropic does.

Cancellation and deadline errors still bypass reconnect (IsConnReset excludes
context.Canceled/DeadlineExceeded), verified by TestStreamCancelDoesNotReconnect.
2026-08-04 16:48:42 +00:00
Willow Lopez 23ebe8302f fix(serve): keep ask interactive after model switches (#7450) 2026-08-04 16:41:17 +00:00
YHH 1f5f879de0 fix(agent,desktop): sanitize stored display titles at the read boundary (#7501)
Apply agent.UserPreviewText at the three ingestion seams (LoadBranchMeta, desktop title sidecars, auto-title derivation) so polluted legacy titles self-heal and wrappers can't seed new titles. Fixes the remaining half of #5666
2026-08-04 16:40:49 +00:00
siwenyu666-beep e3ec757beb fix(desktop): make local file paths in chat clickable (#7426) (#7465)
Plain Windows drive paths, UNC paths and file:/// URLs in AI replies are now rendered as clickable links that open via the native OpenLocalPath binding instead of the system browser.

- remark plugin rewrites path text nodes into file:/// links (CJK punctuation, escaped spaces, trailing closers, %xx round-trip)
- RichMarkdownLink routes file:/// clicks to OpenLocalPath; http links keep using the system browser
- new OpenLocalPath backend binding: absolute-path validation, remote-workspace rejection, executable-target refusal
- 48 unit cases + 10 headless click e2e cases; Go tests included
2026-08-04 16:32:41 +00:00
ryukeilee 5abf294509 feat(evidence): recognize swift test as a delivery verification command (#7480)
* feat(evidence): recognize swift test as a delivery verification command

Why: Swift projects using SwiftPM had no host-recognized verifier for
delivery finalization, so every sign-off fell back to git diff --check.
Add the classifier branch so swift test (like cargo/dotnet test) counts
as a non-mutating verification, while swift build/run/package stay
fail-closed mutations since they emit binaries or mutate the package.

* fix(evidence): reject swift test --xunit-output as a verifier

Why: review found swift test --xunit-output report.xml writes a JUnit
report to an arbitrary path but was still classified as a non-mutating
verification. Add xunit-output to writeOutputFlags (same treatment as
pytest --junitxml / gotestsum --junitfile) and cover it in the Swift
negative cases; document the --enable-code-coverage boundary.

* fix(evidence): reject swift test scratch-dir redirects as verifiers

Why: review found --scratch-path (legacy alias --build-path) redirects
the SwiftPM build dir outside the package, and --generate-linuxmain was
flagged as a source-tree writer. Verified against Swift 6.0.3: only
--scratch-path exists, so add it to writeOutputFlags and document the
boundary; cover the = form of --xunit-output and back the
--enable-code-coverage acceptance with a test.

* fix(evidence): actually reject swift test --build-path

Why: the previous commit's comment claimed --build-path is a legacy
alias of --scratch-path but only scratch-path was added to
writeOutputFlags, leaving swift test --build-path fail-open. Add both
flags and cover the legacy form in the negative test cases.

* test(evidence): cover --build-path equals form in swift negatives

Why: review nit noted --xunit-output tests both space and equals forms
but --build-path only the space form; the equals form exercises the
same TrimLeft/equals-trim path as other flags, so pin it explicitly.

* fix(evidence): reject Swift 6.x event-stream and attachments flags

Why: security review found swift test (Swift 6.x) gained four write
flags not covered by writeOutputFlags: --event-stream-output-path /
--experimental-event-stream-output (swift-testing JSON output) and
--attachments-path / --experimental-attachments-path (Swift Testing
attachments dir). All four write to caller-specified paths and would
bypass the mutation gate. Add them with negative test cases and note
the Package.swift build-plugin trust boundary in the swift comment.

* fix(evidence): align gofmt, reject cache-path and swift control modes

Why: security review flagged gofmt misalignment (CI would fail), the
--cache-path shared-cache redirect was not rejected, and control modes
(--help/-h/--version/--list-tests/-l) that do not run the test suite
could masquerade as verification receipts. Mirror the tsc treatment of
--help in the swift branch and add negative cases.
2026-08-04 16:32:13 +00:00
YB ed038ddd59 fix(config): stop custom providers inheriting DeepSeek's defaults (#7386)
* fix(config): stop custom providers inheriting DeepSeek's defaults

A user-defined provider silently acquired DeepSeek's balance_url, USD price
table and context_window = 1000000 the first time its config was loaded, and
those values were then persisted into the user's own file on the next rewrite.

TOML array-of-tables decoding is positional: BurntSushi/toml unifies
[[providers]][i] onto whatever the destination slice already holds at index i
rather than onto a zero value. Every config load seeds from Default(), which
ships two official DeepSeek entries, so the first two [[providers]] a user
declares were overlaid onto them and inherited every field left unset.

Consequences were not limited to cosmetics. A self-hosted endpoint was pointed
at api.deepseek.com/user/balance, cost readouts applied a foreign price table,
and compaction was sized against a 1M window instead of the real one, so it
never fired before the endpoint began rejecting requests.

Re-decode the providers array onto an empty slice so a declared entry carries
only what the file declares. Endpoint-keyed backfills still apply the defaults
that are genuinely correct for an entry, so official DeepSeek providers keep
their pricing.

Fixes #7357
Fixes #7358

* fix(config): keep official DeepSeek endpoint defaults after provider isolation

Isolating the decoded provider list stopped custom providers inheriting
DeepSeek's fields, but it also dropped context_window and balance_url for
configs that declare the official endpoint and legitimately omit them. Runtime
then saw ContextWindow == 0, which disables compaction, and an empty
balance_url removes the wallet readout.

Reapply both from backfillDeepSeekOfficialPrices, which already runs on the
runtime and edit loaders and already matches on api.deepseek.com. Keying on the
endpoint is what makes the values safe to infer, and is why this cannot leak
onto a custom provider the way the positional decode overlay did.

Declared values are never overwritten, so narrowing the window for the official
endpoint still works.

This also fixes a pre-existing gap: on main-v2 the runtime loader already
returned ContextWindow == 0 for an official provider that omitted the field,
because only the edit loader benefited from the accidental overlay.
2026-08-04 16:26:54 +00:00
Ahmed Al Busaidy 88ac28dac1 fix(provider): raise model-list response cap with truncation detection (#7383)
The 256 KB LimitReader on model-list responses truncates OpenRouter's
~531 KB response, causing a JSON parse failure. Raised to 2 MiB with
overflow detection (the +1 pattern used throughout the codebase), and
extracted into a named constant matching the project's conventions.

Fixes #6746
2026-08-04 16:23:45 +00:00
LI Xinyu 85d2a01758 fix(cli): resolve global hooks from platform Reasonix home in hook machine (#7441)
runHookCommand defaulted options.homeDir to config.ReasonixHomeDir(),
but hook.Inspect treats HomeDir as an OS user home and appends .reasonix
inside the hook package. The double append resolved global hooks from
<home>/.reasonix/.reasonix/settings.json instead of the real
<Reasonix home>/settings.json, so 'reasonix hook status|list --json'
reported global hooks as missing everywhere, and on Windows even the
single .reasonix append was wrong (%AppData%\Roaming\reasonix).

Leave HomeDir empty so the hook package resolves the platform Reasonix
home itself — correct on every OS and with --home-dir overrides
unchanged.

Fixes #7420
2026-08-04 16:19:58 +00:00
SivanCola 007b7cc54b fix(updater): recover superseded pending transactions (#7376)
Problem:
Versioned installations can inherit a valid pending update transaction that still targets the former flat install layout. Recovery rejects that transaction as outside the current Guard installation, so every later update remains blocked even after reinstalling.

Root cause:
The superseded-transaction recovery path called the ordinary transaction reader. That reader correctly requires rollback targets to belong to the current launcher directory, but the requirement makes the recovery path reject the exact flat-to-versioned transition it was designed to heal.

Fix:
Validate superseded transactions through a dedicated fail-closed path that proves the current executable is the active versioned desktop, reuses the ordinary transaction and backup validation, and requires every old release member to belong directly to the same flat install root. Re-read the full transaction identity under the pending lock, archive with no replacement, verify the moved transaction, and preserve rollback backups. Run recovery before download and again before install so users do not pay bandwidth or retry costs for a recoverable state.

Verification:
Root and Desktop Go tests and vet passed. Focused repair and updater tests, deterministic concurrency coverage, race tests, repeated recovery runs, and Windows amd64 cross-compilation passed.
2026-08-05 00:16:09 +08:00
YHH b98aff3201 fix(agent): let the planner conclude answer-only turns without an executor round (#7504)
Extend the [no_changes] contract to answer-only turns so the relay path handles pure Q&A (one answer, no executor round); strip the contract lines from display like [goal:*]. Fixes #6789
2026-08-04 16:04:23 +00:00
YHH d3f9f52b8d fix(acp): replay the user-authored view, not the persisted wire form (#7502)
Reuse UserMessageText / DisplayAssistantText at ACP replay so injected wrappers and protocol markers never reach the client; history untouched. Fixes #6882
2026-08-04 15:56:14 +00:00
YHH cf763955f4 feat(control): send unknown slash input as a regular message (#7496)
Unknown slash input falls through to a normal model turn at the controller fallthrough and the TUI's local dispatch, with the typo notice kept. MCP prompt namespace misses keep their hard error. Fixes #5756
2026-08-04 15:42:56 +00:00
YHH 7f8d700828 fix(desktop): stop empty sessions hijacking topic resolution (#7494)
Content-bearing sessions outrank content-free ones in topic resolution; newest-wins only among equals. Lazy newest-first content probe keeps the common case at one file read. Fixes #7305
2026-08-04 23:38:03 +08:00
YHH 0c2893af06 fix(agent): stop autoresearch evidence blocks leaking into displayed answers (#7493)
Unify assistant display filtering into agent.DisplayAssistantText (goal markers + evidence blocks), applied at both Message emission points; controller reuses the shared strip; TUI adopts the canonical Message text. History stays raw for parsing. Fixes #6665
2026-08-04 15:33:58 +00:00
YHH 55f238f9c7 fix(tui): snapshot the session on every quit gesture (#7489)
Double Ctrl+C, Ctrl+C after cancel, Ctrl+D, typed exit/quit/:q, and /quit //exit returned tea.Quit directly, skipping the Snapshot + lease-follow finalization. Route all quit gestures through tuiShutdownMsg so every exit persists the session. Fixes #5879
2026-08-04 23:03:51 +08:00
liaoyl830 e8be55eef3 fix(tui): preserve queue cursor when editing queued feedback (#4916)
Typing while browsing a queued item no longer resets queueEditCursor, so Enter saves the edit in place instead of appending a duplicate. Fixes #4877
2026-08-04 22:27:55 +08:00
SivanCola 21c0ae388b fix(release): load recovery control in npm job (#7482) 2026-08-04 13:53:48 +00:00
SivanCola 2a9fa2e5d8 fix(release): load npm recovery control plane (#7478) 2026-08-04 21:42:40 +08:00
SivanCola 1474c5204a fix(release): tolerate forbidden npm staging cleanup (#7477) 2026-08-04 21:18:50 +08:00
YHH 86b28aaf84 docs(desktop): correct the Windows signing state (#7475)
The section claimed no Authenticode certificate exists. Stable Windows
builds have been signed through SignPath since desktop-v1.14.1, and the
release fails if any payload binary is unsigned — the stale text sent at
least one reporter chasing signing as the cause of an unrelated network
problem. macOS is still unsigned; say so separately.
2026-08-04 21:00:04 +08:00
YHH e9e6589b3a fix(sysproxy): record why Windows proxy detection fell through (#7474)
WinHttpGetIEProxyConfigForCurrentUser fails on a service account or an
RDP session with no per-user IE config, and the failure was returned as
a plain nil — indistinguishable from "the system is set to direct". Log
the errno, and log which of the three sources were present when nothing
matched, so a corporate machine that can't reach the API is diagnosable
from the debug log instead of by guessing.
2026-08-04 20:59:25 +08:00
YHH 78bc2f8266 fix(desktop): zoom on title-bar double click on macOS (#7473)
The Wails drag runtime drops any mousedown with detail != 1, so a double
click on a --wails-draggable region is swallowed rather than reaching the
OS. Windows already handled it here; macOS hides its title bar the same
way and had nothing, so double-clicking the header did nothing at all.
2026-08-04 20:59:16 +08:00
SivanCola f24b095fcb Merge pull request #7471 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 20:15:49 +08:00
SivanCola 0c86e03a67 docs(release): include readable git status labels 2026-08-04 20:15:00 +08:00
YHHandGTC2080 786dc6d33e fix(desktop): label git status in the changes panel (#7469)
The panel printed the raw porcelain code, so an untracked file showed as
"??" and staged/unstaged pairs as "MM". Map the codes to reader-facing
labels in all three locales, passing unknown codes through unchanged.

The unmerged set is matched as whole codes: AA and DD carry no U and
would otherwise read as a plain add or delete.

Co-authored-by: GTC2080 <[email protected]>
2026-08-04 20:12:34 +08:00
SivanCola 7c95b1c9dd Merge pull request #7470 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 20:03:26 +08:00
SivanCola 1415df287e docs(release): include final v1.19.6 fixes 2026-08-04 20:02:41 +08:00
SivanCola 90082f993d Merge remote-tracking branch 'origin/main-v2' into release-notes/v1.19.6 2026-08-04 20:02:07 +08:00
SivanCola 6d8ec2220a Revert "docs(release): prepare v1.19.6 notes"
This reverts commit 17a5244606.
2026-08-04 20:02:07 +08:00
github-actions[bot] 17a5244606 docs(release): prepare v1.19.6 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-04 12:01:13 +00:00
YHH 36d697587c fix(desktop): move the workspace toggle to the end of the topic bar (#7468)
Workbench rendered the collapse/expand control first in the action row,
so it sat left of copy, export, changed, terminal and the palette —
visually mid-bar rather than against the edge of the panel it controls.
Creation already rendered it last; render one block at the end for both.
2026-08-04 20:00:09 +08:00
YHH 0f5a900c45 fix(session): drop leading @file refs from the session preview (#7464)
Prompts that open with @path references produced a preview — and so a
sidebar title — made entirely of paths, with the actual request pushed
past the 80-rune clamp. Skip the leading reference run; a prompt that is
nothing but references keeps them.
2026-08-04 19:59:01 +08:00
SivanCola 35e6cffead Merge pull request #7467 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 19:42:42 +08:00
SivanCola 61fee7e38e docs(release): review v1.19.6 late merges 2026-08-04 19:41:56 +08:00
SivanCola a5aa171f23 Revert "docs(release): prepare v1.19.6 notes"
This reverts commit 26765dcdf6.
2026-08-04 19:41:01 +08:00
github-actions[bot] 26765dcdf6 docs(release): prepare v1.19.6 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-04 11:40:16 +00:00
YHH 80228bf4a7 fix(provider): name the payload that failed to decode in a stream (#7461)
A gateway putting non-JSON on a data: line produced only "invalid
character 'D' looking for beginning of value", which identifies neither
the sender nor what arrived — the reports it generates are unactionable.
Quote a bounded excerpt of the offending payload instead.
2026-08-04 19:35:10 +08:00
YHH 9271090815 fix(provider): honor a Retry-After longer than the backoff cap (#7459)
A rate-limit window is routinely longer than the 15s exponential-backoff
ceiling, and clamping the server's Retry-After to it spent attempts
re-hitting the same closed window until MaxRetries ran out. Honor the
header up to 60s, and accept its HTTP-date form as well as delta-seconds
— gateways in front of rate-limited backends send both.
2026-08-04 19:35:02 +08:00
YHH 99cf4ae5f3 fix(desktop): scale transcript text with the text-size setting (#7457)
Thinking headers, tool card labels, attachment and pasted-block chrome,
memory citations and the inline edit box carried literal pixel sizes, so
Ctrl+= and the appearance setting left them fixed while the surrounding
message text grew. Point them at the proportional tokens the transcript
region already redeclares.
2026-08-04 19:34:55 +08:00
YHH 4bc7f43478 fix(subagent): treat a non-directory store path as an error on Windows (#7456)
os.ReadDir on a regular file reports ENOTDIR on unix but ENOENT on
Windows, so the IsNotExist branch accepted a corrupt store as "no
subagents yet" and startup continued past it. Confirm the path is
really absent before taking that branch.

TestNewSubagentStoreNeverCachesCleanupError failed on every Windows
checkout for this reason.
2026-08-04 19:34:46 +08:00
YHH c303e077eb feat(glob): bound the recursive walk with a timeout (#7455)
A deep or network-backed tree could keep glob walking for tens of
minutes with no way out short of interrupting the turn. Mirror grep:
default the walk to 30s, accept timeout_seconds up to 300, and return
the matches found so far labelled as incomplete rather than an error.
Cancellation still surfaces as an error so an interrupt is not mistaken
for a finished search.
2026-08-04 19:34:39 +08:00
SivanCola 72be016952 Merge pull request #7462 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 19:25:26 +08:00
SivanCola ada5bdf8fd docs(release): bind v1.19.6 notes to final runtime 2026-08-04 19:24:47 +08:00
SivanCola 7aabfaeb87 Revert "docs(release): prepare v1.19.6 notes"
This reverts commit 4cb097d619.
2026-08-04 19:24:19 +08:00
github-actions[bot] 4cb097d619 docs(release): prepare v1.19.6 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-04 11:23:11 +00:00
SivanCola e37e035bd5 Merge pull request #7458 from esengine/codex/fix-release-v1.19.6-windows-gate
fix(release): make Windows full gate portable
2026-08-04 19:21:49 +08:00
SivanCola 238d9a673b fix windows release gate portability 2026-08-04 19:08:18 +08:00
SivanCola b4e4168947 Merge pull request #7454 from esengine/release-notes/v1.19.6
docs(release): 准备 v1.19.6 中英更新日志
2026-08-04 18:42:37 +08:00
SivanCola 288447c8f2 docs(release): refine v1.19.6 product notes
Keep the generated bilingual notes focused on user outcomes, replace the duplicate version title, and declare every affected product surface.

Verification: release catalog validation; release note rendering tests; public PR citation review.
2026-08-04 18:41:35 +08:00
github-actions[bot] 19b78f7ead docs(release): prepare v1.19.6 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-04 10:39:01 +00:00
SivanCola ad763bce81 Merge pull request #7452 from SivanCola/fix/release-v1.19.6-notes-generator
Keep the release-notes JSON budget / 保证发布说明 JSON 输出预算
2026-08-04 18:37:52 +08:00
SivanCola 209cde2eff fix(release): preserve the notes JSON budget
Problem: Prepare release exhausted or truncated the structured response because DeepSeek v4-pro spent the shared generation budget on hidden reasoning.

Fix: disable thinking for deterministic release-note generation, retain the 8K JSON budget, surface the final finish reason, and lock the request contract into the release workflow test.

Verification: node --check scripts/generate-release-notes.mjs; bash scripts/release-workflows.test.sh; git diff --check.
2026-08-04 18:22:01 +08:00
SivanCola 9b3a452f1f Merge pull request #7449 from SivanCola/fix/release-v1.19.6-frontend-test
Restore the complete frontend release gate / 恢复完整前端发布门禁
2026-08-04 18:04:10 +08:00
SivanCola d4a416d47d test(frontend): restore the complete release gate
Problem:
The formal frontend test gate could not run because the token-format test imported an undeclared Vitest dependency. After that was exposed, a subagent layout contract still asserted the pre-responsive CSS value.

Root cause:
The token-format test was verified outside the repository test runner and never added to the package scripts. The responsive subagent layout fix changed minmax(200px, 1fr) to minmax(0, 1fr) without updating its source contract test.

Fix:
Use the repository's Node test APIs, execute the token-format coverage from pretest, and align the subagent contract with the shrinkable equal-column layout.

Verification:
pnpm --dir desktop/frontend exec tsx src/__tests__/format-tokens.test.ts
pnpm --dir desktop/frontend exec tsx src/__tests__/capabilities-panel-actions.test.ts
pnpm --dir desktop/frontend test:all
pnpm --dir desktop/frontend build
2026-08-04 17:51:19 +08:00
YHH fd04316dfa fix(tui): show the whole command on a long approval (#7447)
The approval banner clipped the subject to one line, so a long command
hid the part that decides the answer — the path being written or the
flag that makes it destructive. Clipped subjects now expand below the
banner, wrapped and bounded so a heredoc cannot push the composer away.
2026-08-04 17:48:59 +08:00
YHH b42f0f6cd6 fix(config): migrate global hooks to the new home (#7445)
The legacy-path migration copied sessions, projects, skills, archive and
hooks.json, but global hooks live in settings.json — so upgrading to the
unified home silently emptied them. Adds settings.json, and stops any
file item from overwriting one already present at the destination.
2026-08-04 17:32:07 +08:00
SivanCola b47a757460 Merge pull request #7446 from SivanCola/fix/consolidate-npm-security-alerts
fix(deps): resolve npm security advisories / 修复 npm 依赖安全告警
2026-08-04 17:30:48 +08:00
SivanCola bf14218723 fix(deps): resolve npm security advisories
Problem:
Dependabot reports 24 open alerts across the frontend and Worker lockfiles for vulnerable Hono, Undici, and PostCSS versions.

Root cause:
The Hono manifests allowed an affected release, while Miniflare and jsdom still pin Undici 7.28.0 transitively. The crash-report Vite tree also retained an affected PostCSS release.

Fix:
Raise the Hono security floor, pin Undici 7.29.0 through package-manager-native overrides, pin PostCSS 8.5.23 for crash-report, and regenerate all affected lockfiles.

Verification:
- pnpm/npm audit in all four projects
- Worker TypeScript checks
- crash-report Vitest suite (83 tests)
- desktop frontend production build and bundle budgets
- Wrangler dry-run builds for all three Workers
2026-08-04 17:27:19 +08:00
SivanCola 71533a6a4a Merge pull request #7405 from zdjmrq/fix/issue-7399
fix(desktop): lifecycle-safe subagent cleanup / 修复子代理清理竞态
2026-08-04 17:26:13 +08:00
YHH 2c0e6c9437 fix(permission): treat destructive git commands as destructive (#7437)
git tag counted as read-only regardless of arguments, so `git tag -d` and
tag creation passed the read-only boundary that plan mode and capability
runners rely on. Restore/checkout-discard/stash-drop carried no danger
label either, which also let them earn a broad session prefix grant.
2026-08-04 17:17:21 +08:00
YHH 9fdca5e163 fix(boot): keep the executor when planner_model is unresolvable (#7434)
A planner_model naming a provider that no longer resolves failed the
whole build, so the desktop opened into a startup error with no usable
chat — a stale ref from recovered config was enough. The planner is
optional; it now degrades to executor-only with a notice, matching how
the guardian model already behaves.
2026-08-04 17:04:37 +08:00
SivanCola aae762db12 Merge pull request #7430 from Li-Charles-One/fix/creation-hero-stack-center
fix(creation): 空会话 hero 标题+输入框一体居中
2026-08-04 16:52:30 +08:00
SivanCola beb2946d4d chore: merge main-v2 into fix/issue-7399
Problem:
The new documentation-impact workflow checks out the PR head, but this branch predates the guard script and fails before evaluating the PR declaration.

Root cause:
The branch was behind main-v2 after the documentation guard and later desktop fixes landed.

Fix:
Merge current main-v2 so the PR is evaluated with the same guard scripts and integration baseline used by the target branch.

Verification:
- merge completed without conflicts
- focused cleanup and desktop tests passed on the merged tree
- focused race tests passed on the merged tree
- root full suite and vet checks passed on the preceding latest-base integration tree

Co-authored-by: SivanCola <[email protected]>
2026-08-04 16:38:55 +08:00
Li-Charles-One 01658c03d0 fix(creation): skip empty hero when IM/Bot detail is open
creationEmptyHero collapsed .main for empty sessions, which also hosts SidebarImConnectionDetail. Exclude sidebarImDetailConnection and assert in app-chrome-tabs.
2026-08-04 16:34:33 +08:00
SivanColaandsiwenyu666-beep ec2cd41f58 fix(desktop): make stale subagent sweeps lifecycle-safe
Problem:
Concurrent desktop controller builds could bypass an in-flight sweep, while the permanent process marker prevented later crash leftovers from being repaired.

Root cause:
A boolean sync.Map conflated in-progress and completed cleanup and never invalidated successful entries.

Fix:
Run cleanup on every build, protect other live desktop parents with a process-local liveness probe, and let an initial build repair its own crash leftovers before binding the session lease. Keep the bounded startup lease retry as defense in depth.

Verification:
- go test ./internal/agent ./internal/boot -run Test(SubagentStoreCleanupStaleRunning|NewSubagentStore) -count=20
- go test -race ./internal/agent ./internal/boot -run Test(SubagentStoreCleanupStaleRunning|NewSubagentStore) -count=5
- cd desktop && go test . -run Test(SessionParentLive|CleanupStaleRunningWithDesktopParentProbe|WithSessionLeaseContentionRetry) -count=20
- cd desktop && go test -race . -run Test(SessionParentLive|CleanupStaleRunningWithDesktopParentProbe|WithSessionLeaseContentionRetry) -count=5
- go test ./...
- go vet ./internal/agent ./internal/boot
- cd desktop && go vet .

Co-authored-by: SivanCola <[email protected]>
Co-authored-by: siwenyu666-beep <[email protected]>
2026-08-04 16:33:40 +08:00
YHH 5635adf7b7 fix(desktop): stop recreating a deleted workspace folder (#7432)
Topic-state writers ran MkdirAll on <root>/.reasonix, so a workspace
folder deleted outside Reasonix came back on the next launch — the tab
still pointed at it and the first title write rebuilt the whole path.
Project-scoped writes now require the root to still exist; the global
store is unaffected.
2026-08-04 16:28:56 +08:00
Li-Charles-One 4ce7029526 fix(creation): center empty hero title+composer as one stack
Keep the headline above the slim composer in the same footer stack so the pair centers together. Also force-remove footer border-top under Creation (theme-style specificity).
2026-08-04 16:20:36 +08:00
SivanCola fce1223d19 Merge pull request #7402 from SivanCola/feature/embedded-versioned-docs
feat(docs): Embed version-matched product docs / 内置版本匹配产品文档
2026-08-04 16:00:29 +08:00
SivanCola 7e68366c9e Merge pull request #7421 from SivanCola/fix/bot-notice-audience
Keep recovery notices out of IM chats / 避免向 IM 聊天转发恢复通知
2026-08-04 15:59:20 +08:00
SivanCola c993eaad24 fix(desktop): suppress operator notices in IM forwarding
Problem:
The Desktop bot bridge remained a second IM delivery sink that forwarded every warning, including recovery notices explicitly scoped to local operators.

Root cause:
The new notice-audience policy was enforced only by the CLI bot renderer. The Desktop event forwarder still treated warning severity as end-user delivery policy.

Fix:
Skip operator-scoped notices before the Desktop forwarder queues IM output, while preserving ordinary user warnings. Add a deterministic queue-level regression that proves the two behaviors together.

Verification:
- cd desktop && go test . -run TestBotEventForwarder -count=1
- cd desktop && go test -race . -run TestBotEventForwarder -count=1
- cd desktop && go test ./...
- git diff --check
2026-08-04 15:53:34 +08:00
SivanCola 671808a9cb fix(docs): bound search and frame corpus digests
Problem:
- documentation search accepted unbounded queries before tokenization and BM25 scoring
- delimiter-only corpus hashing did not encode file boundaries unambiguously

Root cause:
- the shared search path had no provider-visible or runtime query limit
- digest input concatenated names and bytes with a delimiter that valid UTF-8 content could contain

Fix:
- cap search queries at 4096 Unicode characters in both schema and runtime
- hash a versioned stream of length-prefixed file records
- add regression coverage for both entry paths and the legacy framing collision

Verification:
- go test ./...
- go test -race ./internal/productdocs ./internal/boot ./internal/control ./internal/cli ./internal/remote/workbench/runtime
- ./scripts/cache-guard.sh
- pnpm --dir desktop/frontend build
- npm test and npm run build in site/
2026-08-04 15:46:10 +08:00
SivanCola 0ac5bb9bb6 docs(site): explain embedded docs search
Documentation:
- add bilingual guidance for the embedded documentation command
- explain qualified-name fallback when another skill owns /docs

Verification:
- npm test in site/
- npm run build in site/
2026-08-04 15:40:55 +08:00
YHH 33902e96f3 fix(cli): authenticate release lookups with GITHUB_TOKEN (#7418)
The GitHub API fallback ran anonymously, and that quota is 60/hour per
IP — shared by everyone behind the same NAT, so `reasonix upgrade` could
fail with 403 on a network where nobody had upgraded that hour. Uses
GITHUB_TOKEN/GH_TOKEN when set, and says so when a refusal is the quota.
2026-08-04 15:39:05 +08:00
SivanCola 6acd67f254 Merge pull request #7225 from Lxiny-zy/fix/subagent-corrupt-meta-startup
fix(agent): skip corrupt subagent metadata during startup cleanup
2026-08-04 15:37:27 +08:00
SivanCola 29bea9ff06 fix(bot): keep recovery notices out of IM chats
Problem:
Successful local session recovery emitted Warn notices that the CLI bot gateway forwarded to WeChat, QQ, and Feishu as if they were actionable user warnings.

Root cause:
Notice severity doubled as delivery policy. Recovery events had neither an explicit audience nor stable identities, so every Warn notice was treated as an end-user chat message.

Fix:
Add a backward-compatible operator notice audience and stable recovery codes, mark every controller recovery outcome as operator-only, and suppress only that audience in the bot renderer. Preserve ordinary user warnings and local recovery handling. Use the stable codes for Desktop localization and quiet-notice routing, with text fallback for older records.

Verification:
- go test ./...
- go test -race ./internal/event ./internal/control ./internal/bot
- go vet ./...
- cd desktop && go test ./...
- pnpm build
- focused frontend recovery notice tests
2026-08-04 15:34:23 +08:00
SivanCola 3ff2573dc3 fix(agent): keep cleanup lease across corrupt rereads
Problem: CleanupStaleRunning released a parent session lease before skipping a corrupt metadata reread, so later refs for the same parent could be rewritten without ownership protection.

Root cause: The lease release ran before the code classified the reread failure as a skippable decode error or a fatal storage error.

Fix: Retain the parent lease for skippable decode failures, release it only before fatal returns, and add a deterministic interleaving test that corrupts the first reread and probes the lease before the second ref.

Verification: go test ./internal/agent -count=1; go test -race ./internal/agent -count=1; go vet ./internal/agent; go test ./internal/boot -count=1; go test ./... -count=1; focused cleanup tests passed 20 times normally and 5 times under race.

Co-authored-by: SivanCola <[email protected]>
2026-08-04 15:18:13 +08:00
SivanCola 0c44effe8d fix(docs): unify slash command ownership
Problem:
Hidden plugin command and skill aliases could own /docs at runtime while Desktop and remote menus still advertised the built-in documentation command.

Root cause:
Dispatch, CLI discovery, Desktop discovery, and remote session catalogs derived slash ownership independently, and the remote protocol did not advertise host-owned built-in routes.

Fix:
Centralize slash ownership resolution, preserve every existing command, allocate a deterministic conflict-free Reasonix-qualified fallback for built-in docs, and publish the host-resolved route through an additive optional catalog field. Update local help and bilingual documentation to show the effective invocation.

Verification:
- go test ./...
- cd desktop && go test ./...
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:composer-menu-viewport
- go test -race ./internal/productdocs ./internal/control ./internal/cli ./internal/remote/workbench/runtime
- ./scripts/cache-guard.sh
- go run ./cmd/remote-protocol-gen --check
- bash scripts/check-docs-impact.test.sh
2026-08-04 15:14:52 +08:00
SivanCola 510abd2b69 Merge pull request #7344 from SivanCola/fix/cli-recovery-session-hygiene
Fix CLI recovery session proliferation and resume clarity / 修复 CLI 恢复会话膨胀与识别
2026-08-04 15:07:24 +08:00
SivanCola 41cfc15fe2 Harden CLI recovery trash publication
Problem:
CLI recovery GC exposed a Desktop trash entry before its authoritative event log and sidecars had moved. A crash, restore, or purge in that window could strand a split artifact set. Numeric /resume also ran GC after completion had assigned an index, allowing the index to shift before execution.

Root cause:
The cleanup protocol moved the primary transcript before publishing its durable operation marker, and the numbered resume path mutated the same ordered list it was about to resolve.

Fix:
Stage the transcript and all owned artifacts in a hidden, durably marked directory, reconcile interrupted stages on startup, and publish the complete trash directory with one same-filesystem rename. Retain compatibility for the earlier recovery-trash marker protocol. Keep numbered resume resolution read-only and update its ordering documentation.

Verification:
- go test ./internal/agent ./internal/cli
- go test -race ./internal/agent ./internal/cli
- go vet ./...
- go test ./...
- cd desktop && go test ./...
2026-08-04 14:55:18 +08:00
SivanCola 19d5707fce fix(agent): classify corrupt subagent metadata
Problem: CleanupStaleRunning still aborted startup for custom decoder failures such as invalid time values, and its lease-protected second read had no corruption tolerance.

Root cause: LoadMeta wrapped read and decode failures with only textual context, so callers had to enumerate concrete JSON error types.

Fix: Return a private typed decode error, skip that category at both cleanup reads, preserve fatal I/O behavior, and cover invalid timestamps alongside truncated and empty metadata.

Verification: go test ./internal/agent -count=1; go test -race ./internal/agent -count=1; go vet ./internal/agent; go test ./internal/boot -count=1; go test ./... -count=1; focused cleanup tests repeated under normal and race builds.

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:52:31 +08:00
YHH 80ae90e856 fix(desktop): warn when a workspace config overrides settings (#7413)
Settings writes the user config, but a workspace reasonix.toml outranks
it, so toggles like sandbox and permission mode could read one way and
behave another with nothing on screen to explain it. The panel now names
the file actually in effect when it isn't the one being written.
2026-08-04 14:48:54 +08:00
SivanCola c72b0c54dc Merge pull request #7369 from SivanCola/fix/macos-orphan-update-backup-recovery
fix(updater): recover orphaned macOS app backups / 恢复 macOS 更新残留备份
2026-08-04 14:38:14 +08:00
SivanCola 82518d1bcc Merge pull request #7309 from skyzhao1223/fix/subagent-created-at-meta
fix(agent): keep subagent list CreatedAt as start time
2026-08-04 14:26:52 +08:00
SivanCola 39eea11f70 merge(main-v2): resolve frontend test script overlap
Integration: Merge main-v2 at 8c8f9410e5 so PR #7402 remains mergeable without rewriting existing history.

Resolution: Preserve the base settings-responsive test and pretest hook while retaining the PR virtual-menu identity regression in test:composer-menu-viewport.

Verification: go test for productdocs, control, CLI, fileref, and Desktop; focused race tests; frontend typecheck; composer menu, settings responsive, app chrome, and token-format matrices; go vet and diff checks.
2026-08-04 14:25:22 +08:00
SivanCola 7fc5c669eb fix(docs): preserve exact release and slash resolution
Problem: Stable release queries could score preview notes as exact matches, while CLI and Desktop discovery could show duplicate /docs entries when custom overrides existed.

Root cause: Version boosting matched virtual paths by substring, and discovery surfaces appended builtin, skill, and custom entries independently of runtime dispatch precedence.

Fix: Track the structured release version for exact score boosting and resolve /docs discovery to the runtime-winning custom command, skill, or builtin across CLI completion, CLI help, and Desktop.

Verification: go test ./internal/productdocs ./internal/control ./internal/cli; cd desktop && go test ./...; focused go test -race for productdocs, CLI, and Desktop; go vet for touched modules; pnpm --dir desktop/frontend test:composer-menu-viewport.
2026-08-04 14:21:04 +08:00
SivanCola 8c8f9410e5 Merge pull request #7362 from HaoyueQin/fix/settings-page-narrow-window-layout
fix(desktop): Fix settings page responsive layout on narrow windows / 修复设置页窄窗口下的响应式布局
2026-08-04 14:19:16 +08:00
SivanCola 0d50826eef fix(agent): persist failed status after sidecar errors
Problem:
The CreatedAt sidecar pre-seed could fail before SaveFailed reached its terminal metadata write, leaving a failed subagent durably marked as running.

Root cause:
SaveFailed returned immediately from ensureBranchCreatedAt even though its existing failure-atomic contract deliberately saved subagent metadata after transcript errors.

Fix:
Accumulate the branch sidecar error, continue transcript persistence, always record the failed terminal status, and join all storage errors. Add a corrupt-sidecar regression that proves the error is reported while failed status still reaches disk.

Verification:
- go test ./internal/agent -run "TestSubagentStoreSaveFailedPersistsTerminalMetaWhenBranchMetaIsCorrupt|TestSubagentStoreTerminalSaveKeepsBranchStartAndActivityTimes" -count=20
- go test ./internal/agent -count=1
- go test -race ./internal/agent -count=1
- go vet ./internal/agent
- go test ./... -count=1
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:16:19 +08:00
HBLADEH f6d6f79ad7 feat(desktop): close tabs with middle click (#4330) 2026-08-04 14:13:12 +08:00
SivanCola a5f500fa15 fix(updater): retire owned orphan backup quarantines
Problem:
Recovered macOS app backups were preserved indefinitely, and the updater had no real protected-directory smoke coverage for the quarantine lifecycle.

Root cause:
The pending transaction did not retain ownership evidence for the quarantined tree, so terminal cleanup could not distinguish it from an unrelated sibling safely.

Fix:
Bind the exact quarantine path and digest to optional transaction fields, include that path in the mutation lock set, and remove it only after an exact terminal transition and digest verification. Preserve changed or concurrently recreated paths. Add an opt-in Darwin smoke test that exercises the real /Applications boundary with a uniquely owned synthetic app.

Verification:
- go test ./...
- go vet ./...
- (cd desktop && go test ./...)
- (cd desktop && go vet ./...)
- go test -race ./internal/repair -run 'AppBundle|PendingUpdate|Handoff|Quarantine|Orphan' -count=1
- REASONIX_MACOS_APPLICATIONS_SMOKE=1 go test ./internal/repair -run TestDarwinApplicationsOrphanBackupLifecycleSmoke -count=1 -v
- pnpm --dir desktop/frontend test:updater
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend build
2026-08-04 14:11:10 +08:00
SivanCola 5ff3021663 fix(desktop): keep memory controls inside wide settings rows
Problem:
The memory tail wrapper stayed a nested flex item above 900px, causing the Suggestions button to overflow the settings row near the breakpoint.

Root cause:
The grouping styles applied at every width, so the outer row shrank the wrapper without preserving the original wide-screen flex relationship.

Fix:
Flatten the wrapper on wide screens and enable its flex grouping only at the narrow breakpoint. Add a focused CSS contract test for both modes.

Verification:
- pnpm --dir desktop/frontend test:settings-responsive
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
- Browser measurements at 900, 901, 910, and 920px

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:09:21 +08:00
SivanCola 4dca2b0b24 Merge pull request #7317 from Li-Charles-One/feat/creation-empty-composer-hero
feat(creation): 精简空会话欢迎页与中间 hero 输入框
2026-08-04 14:07:34 +08:00
SivanCola 2c127bed35 Preserve chronological CLI continue selection
Problem:
Recovery-family presentation ordering made --continue prefer an older recovery leaf over a more recently active parent session.

Root cause:
The non-interactive continue path reused the interactive picker ordering even though --continue promises to resume the most recent session.

Fix:
Select --continue targets directly from the chronological session listing while keeping recovery-family grouping in interactive resume surfaces. Add a regression test that proves the two ordering contracts remain independent.

Verification:
- go test ./internal/cli
- go test -race ./internal/cli
- git diff --check
2026-08-04 14:07:20 +08:00
SivanCola 10e1719096 fix(agent): preserve terminal subagent activity time
Problem:
Seeding branch metadata with the subagent start time also assigned that value to UpdatedAt, so newly completed or failed runs appeared stale in recency-based session ordering.

Root cause:
Session.Save preserves an existing branch UpdatedAt. The pre-seeded start timestamp therefore survived the terminal transcript save and became ListSessionOrder.LastActivityAt.

Fix:
Seed only BranchMeta.CreatedAt and let the terminal metadata write assign UpdatedAt. Replace the timing sleep with deterministic completed/failed coverage that verifies creation time, terminal activity time, and list ordering.

Verification:
- go test ./internal/agent -run TestSubagentStoreTerminalSaveKeepsBranchStartAndActivityTimes -count=20
- go test ./internal/agent -count=1
- go test -race ./internal/agent -count=1
- go vet ./internal/agent
- go test ./... -count=1
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 14:04:38 +08:00
SivanCola 5c89fea841 Merge pull request #7406 from SivanCola/docs/publish-v1.19.5-changelog
Publish v1.19.5 changelog / 发布 v1.19.5 更新日志
2026-08-04 13:55:35 +08:00
SivanCola a953a57731 docs(changelog): publish v1.19.5 notes
Context:
The complete bilingual v1.19.5 release record was present but remained hidden from the public changelog.

Change:
Mark the v1.19.5 catalog record as published so both the changelog index and exact-version route render it.

Verification:
- node scripts/release-notes.mjs validate --version 1.19.5
- npm test --prefix site
- npm run build --prefix site
2026-08-04 13:54:21 +08:00
SivanCola 67264bc66a Merge pull request #7359 from tic-top/docs/acp-set-config-option-params
docs(acp): document session/set_config_option's parameters
2026-08-04 13:53:57 +08:00
字多就没人取 1d42053b08 fix(desktop): stop spurious "session already open" startup errors from subagent sweep races
CleanupStaleRunning runs inside every controller build (boot.Build) and
transiently acquires the session lease of each running sub-agent's parent
session while rewriting its metadata. Desktop tabs share one global session
dir, so concurrent tab builds raced that probe against their own startup
bind: a tab whose bind lost the race surfaced a spurious "this session is
already open in another Reasonix window" error for a lease that was
genuinely free milliseconds later.

Two minimal fixes:

- desktop/app.go: startup lease binds (ensureTabSessionLeaseForRebuild,
  acquireCandidateSessionLease) now retry briefly on ErrSessionLeaseHeld,
  absorbing sub-millisecond in-process contention windows. A lease held by
  another window or process stays held for its whole lifetime and still
  fails fast after the bounded retries.
- internal/boot/boot.go: the stale-running sub-agent sweep runs once per
  session dir per process instead of once per controller build, eliminating
  the repeated global re-scan that created the contention. CLI/serve runs
  are fresh processes, so their sweep-once semantics are unchanged. A failed
  sweep drops its marker so a later build retries.

Adds regression tests for both layers. The desktop test reproduces the exact
reported error message before the fix.

Fixes #7399
2026-08-04 13:51:54 +08:00
YHHandHUQIANTAO 34cfca95d0 feat(desktop): unify token display format with K/M abbreviations (#7404)
Four independent token formatters produced four shapes for the same
number (142000 / 142,000 / 142.0k / 142.0K), and the long forms overflow
the sidebar's token column at its default width.

Co-authored-by: HUQIANTAO <[email protected]>
2026-08-04 13:50:38 +08:00
SivanCola d21b67abc4 fix(docs): avoid deprecated Goldmark heading API
Problem: The CI lint gate rejected the embedded documentation parser because Heading.Text is deprecated in Goldmark v1.8.2.

Root cause: Heading extraction relied on the legacy aggregate Text method instead of reading supported inline AST node values.

Fix: Collect heading text from Text, String, AutoLink, and RawHTML nodes while preserving soft line breaks. Extend the parser regression test to cover emphasis, inline code, auto-links, C#, and Setext headings.

Verification: golangci-lint run --timeout=5m; go test ./...; go test -race ./internal/productdocs.
2026-08-04 13:31:06 +08:00
SivanCola 0c542ceb7c feat(docs): embed version-matched product documentation
Problem:
Reasonix installations could not reliably search the exact documentation and release notes shipped with their build, and dynamic slash-menu filtering could leave stale virtual row gaps.

Root cause:
Documentation lived only as source files without a compiled retrieval identity or deterministic slash-command path. The shared virtual menu also let TanStack Virtual cache rows by numeric index while React used different keys.

Fix:
Embed docs and reviewed release notes, add the read-only docs tool and /docs command across CLI and Desktop, stamp and verify corpus identity in release builds, enforce documentation-impact declarations, and align virtual-list semantic keys across slash and file menus.

Verification:
- go test ./...
- go test -race ./internal/productdocs ./internal/boot ./internal/control ./internal/cli
- cd desktop && go test ./...
- pnpm --dir desktop/frontend build
- ./scripts/cache-guard.sh
- bash scripts/release-workflows.test.sh
- bash scripts/verify-embedded-docs.sh v0.0.0 e67b2aa824
2026-08-04 13:20:08 +08:00
YHH 260dc77c1e fix(fileref): keep build outputs out of @ completion (#7401)
The skip list only knew build/ and dist/ plus this repo's own paths, so a
Maven target/, a Python __pycache__ or a venv flooded the "@" menu with
generated files. Extends it to the common ecosystem outputs and gives the
desktop the same list instead of its own drifting copy.
2026-08-04 13:18:36 +08:00
YHHandttmouse e73a78d685 fix(config): keep project provider_access from hiding user providers (#7395)
An undeclared user-level provider_access means "allow all". Merging it as
a union with a project reasonix.toml that does declare one narrowed the
desktop model switcher to the project's list, so account-level providers
disappeared in that workspace. A project-only list is now ignored; the
union still applies once the user config declares its own.

Co-authored-by: ttmouse <[email protected]>
2026-08-04 12:13:52 +08:00
YHH 71fba1d551 feat(cli): show the active config file in /status (#7393)
A ./reasonix.toml outranks the user-global config, so a session started in
a directory holding one ignores edits to the global file with nothing on
screen to explain it. /status now names the file actually in effect.
2026-08-04 11:50:07 +08:00
YHH 48a78a09f2 fix(cli): accept Ctrl+arrows for composer word motion (#7390)
bubbles binds word-wise cursor motion to Alt+arrows only, which is the
macOS convention; on Windows and Linux terminals Ctrl+Left/Right is what
users press, and it previously did nothing in the composer.
2026-08-04 11:38:24 +08:00
YHH a9c7947a8f fix(cli): enable Windows VT processing before emitting ANSI (#7387)
colorprofile picks a Windows profile from the NT build number alone and
never inspects the console mode, so on a legacy conhost (Win10 PowerShell
outside Windows Terminal) it reports TrueColor while the console prints
the SGR escapes literally. Bubbletea turns VT on for the TUI, but
`reasonix setup`, `config` and `doctor` print outside it.

Enable VT for stdout/stderr at package init and fall back to plain output
when the console refuses it.
2026-08-04 11:25:42 +08:00
YHH e67b2aa824 docs(readme): swap the daily Trendshift badge for the ranked pair (#7378)
The daily badge reads '#2 Repository Of The Day' and goes stale the moment that day passes; the monthly-Go and repository badges stay accurate on their own.
2026-08-04 10:23:53 +08:00
Li-Charles-One 7592c2391e fix(creation): harden empty-session hero layout and hydration
Address PR review: flow-based title/composer stack for small windows, multi-line hero textarea growth, hero only after history hydration, and CSS trim to stay under the frontend bundle budget.
2026-08-04 10:21:24 +08:00
Li-Charles-One 51f75e2418 feat(creation): refine empty-session hero copy and layout
Use a single gradient headline, align it with the slim composer column,
and lift the input closer under the title without clipping descenders.
2026-08-04 10:18:05 +08:00
Li-Charles-One 2512cf7e17 feat(creation): slim empty-session hero composer
Drop welcome prompt cards and lift a short model-only composer under the
headline. On first message, ease back to the full bottom composer with
lighter control weights.
2026-08-04 10:18:05 +08:00
SivanCola c060a2e060 Merge pull request #7356 from SivanCola/feature/local-rewind-reliability
Make local rewind transactional and conflict-safe / 提升本地回滚的事务性与冲突安全
2026-08-04 08:37:02 +08:00
SivanCola 6ffea769e8 fix(updater): quarantine orphaned app backups
Problem:
A macOS update can remain blocked forever when the fixed app-bundle backup directory survives but no pending update transaction exists.

Root cause:
Preparation treated every existing handoff backup as owned rollback material, even when no transaction could identify or recover it.

Fix:
After validating the staged and installed bundles, atomically quarantine the exact orphaned sibling backup under the update locks. Preserve its contents, verify the move, and fail closed if the path or contents change concurrently. Classify failures as recovery errors and provide actionable non-destructive guidance.

Verification:
Focused handoff and reconciliation tests, the internal repair race suite, root and Desktop Go tests and vet, frontend updater tests, typecheck, CSS checks, production build, and diff checks passed.
2026-08-04 08:36:51 +08:00
YHH 5d4df5da70 Merge pull request #7367 from esengine/fix/finalize-npm-idempotent
fix(release): only write the npm aliases that are actually behind
2026-08-04 08:31:31 +08:00
esengine fe5a8f12b8 fix(release): only write the npm aliases that are actually behind
The alias alignment step rewrote latest/canary/next unconditionally, so a
recovery rerun performed three registry mutations per package even when
all seven packages already pointed at the release. npm treats dist-tag
writes as sensitive operations — an automation token is refused where an
interactive session is not — so a rerun with nothing left to do still
failed on the first write it did not need to make.

That is the shape of the v1.19.5 recovery: the publish itself succeeded
and the aliases have to be realigned by hand, after which rerunning the
workflow should be a no-op. It was not.

The step now reads the current aliases first and writes only what is
missing, keeping the candidate-SHA validation ahead of any mutation.

Also fixes the staging cleanup, which named "official-staging" — a tag
nothing creates. The publisher stages under "<dist-tag>-staging"
(npm/publish.mjs), so cleanup never removed anything and a staging alias
left behind by a failed publish stayed on the registry, which is exactly
what happened to latest-staging in 1.19.5.

Tests record every dist-tag invocation, so they assert how many registry
writes each path takes rather than only its exit status: none when the
aliases are already correct, only the stale ones otherwise, none at all
when provenance validation fails, and one staging removal per package
under the name the publisher really uses.
2026-08-03 17:15:16 -07:00
YHH 0dab33c3d0 Merge pull request #7366 from esengine/fix/updater-unusable-transaction
fix(updater): agree on what a pending update is, and recover from debris
2026-08-04 08:07:59 +08:00
esengine 0bad556717 fix(updater): agree on what a pending update is, and recover from debris
Preparation and reconciliation each had their own notion of "a pending
update exists". Preparation checked only that the marker file was
present and refused. Reconciliation read and validated it, and bailed
out without clearing anything when validation failed.

A marker that fails validation therefore satisfied neither: nothing could
resume it, roll it back, or remove it, while its mere presence kept
refusing every future update. Users hit a permanent dead end — retrying
did nothing, and reinstalling did nothing either, because the marker
lives in the state directory rather than the install directory. Startup
then reported the failed recovery and dropped into safe mode, so it
presented as "the whole app is broken" rather than "an update failed".

Both paths now classify through one function. The line between debris
and a recoverable transaction is drawn at self-description: bytes that
do not parse, or that do not say which release they target, for which
platform, and when they were opened, name nothing to roll back to, so
discarding them loses nothing. Every other validation failure is
environment-relative — the launcher path, whether the target sits inside
this installation, where the backup lives — and can fail for a perfectly
good transaction observed from the wrong install, so those keep the old
refusal rather than risking real rollback material. IO failures stay
errors: an unreadable marker is not an absent one.

Debris is quarantined rather than deleted. It is the only evidence of
what went wrong, and someone reporting a stuck updater should still have
it; repeated recovery never overwrites an earlier copy.

Fixes #7342.
2026-08-03 16:52:18 -07:00
SivanCola fc53cef919 Satisfy rewind picker lint
Problem:
CI lint rejected two stage-dependent if/else chains in the rewind picker with staticcheck QF1003.

Root cause:
The new confirmation state handling used repeated comparisons against the same stage value.

Fix:
Use tagged switches for Escape and Enter stage transitions without changing behavior.

Verification:
go test ./internal/cli
golangci-lint run --timeout=5m ./internal/cli
git diff --check
2026-08-04 04:22:29 +08:00
SivanCola ac91369c31 Harden checkpoint coverage in headless and CLI rewind
Problem:
Headless turns did not open checkpoints, partial-coverage CLI rewinds could commit without an explicit warning, and an empty hook runner incorrectly downgraded every file checkpoint to partial coverage.

Root cause:
Controller.Run skipped the checkpoint lifecycle used by interactive turns, the TUI called the compatibility Rewind wrapper directly, and hook coverage was inferred from a non-nil runner instead of configured tool-hook events.

Fix:
Open checkpoints for headless turns, require preview and explicit confirmation before partial file rewinds, make the compatibility wrapper fail closed, and report hook-write gaps only for actual tool hooks while keeping unknown custom hooks conservative.

Verification:
go test ./internal/control ./internal/cli ./internal/agent ./internal/hook ./internal/i18n
go test -race ./internal/control ./internal/cli ./internal/agent ./internal/hook
go test ./...
go vet ./...
(cd desktop && go test ./...)
./scripts/cache-guard.sh
2026-08-04 04:19:14 +08:00
SivanCola cf9ee2823f fix(rewind): close transaction recovery gaps
Problem:
Local rewind could leave split state around crash and compensation windows, expose unsafe legacy file restores, and lose undo metadata in Desktop paths.

Root cause:
Checkpoint persistence and compensation did not model every intermediate state, queued writers were registered too late, and UI capability propagation trusted incomplete ownership metadata.

Fix:
Make checkpoint expiration and truncation failure-aware, verify blob identities, recover rename intermediates, retain pending failed compensation, normalize paths, require final ownership fingerprints, reserve queued background writers, and preserve Desktop transaction IDs for undo.

Verification:
go test ./...
go test -race ./internal/checkpoint ./internal/control ./internal/agent
cd desktop && go test focused rewind/workspace suites
cd desktop/frontend && pnpm build
./scripts/cache-guard.sh
go vet and golangci-lint for root and desktop modules
2026-08-04 03:17:52 +08:00
SivanCola 34af76be31 Merge pull request #7364 from esengine/release-notes/v1.19.5
docs(release): 准备 v1.19.5 中英更新日志
2026-08-04 02:10:28 +08:00
SivanCola 48d5519d70 test: honor platform modes in desktop rewind
Problem: the authoritative Desktop rewind test reported a file mode conflict on Windows.\n\nRoot cause: its seeded checkpoint used Unix 0644 permissions instead of the effective platform mode.\n\nFix: record the target file's actual permission bits in the checkpoint fixture.
2026-08-04 02:09:39 +08:00
SivanCola ee6c8e6c29 docs(release): prepare v1.19.5 notes
Summary:
Generate a bilingual, product-focused release record for the v1.19.5 Stable candidate from merged pull request metadata.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-04 02:09:37 +08:00
SivanCola bf0be67b7c Merge pull request #7363 from SivanCola/docs/add-trendshift-badge
docs: add Trendshift achievement badge / 文档:添加 Trendshift 成就徽章
2026-08-04 01:59:03 +08:00
SivanCola 5928fe8344 Merge pull request #7361 from SivanCola/fix/settings-lazy-css-test-loader
Fix lazy Settings CSS test loading / 修复设置懒加载 CSS 测试
2026-08-04 01:58:07 +08:00
SivanCola 59e6f7e4bf docs: add Trendshift achievement badge
Summary:
- Add the official Trendshift daily achievement badge to the English and Chinese README pages.
- Link the badge to Reasonix's Trendshift repository profile.

Verification:
- git diff --check
- Verified the badge endpoint returns SVG and renders in the local README preview.
2026-08-04 01:57:39 +08:00
SivanCola d9b17274ba test: use platform file mode in rewind recovery
Problem: the Windows smoke test treated the seeded recovery fixture as an external file modification.\n\nRoot cause: the fixture hard-coded Unix 0644 permissions even when the platform reports a different effective mode.\n\nFix: derive restore and forward identity modes from the file's actual stat result.
2026-08-04 01:50:25 +08:00
SivanCola 4d0eaf082c fix: remove obsolete rewind helper
Problem: CI lint rejected the checkpoint manager helper after compatibility rewind moved to the transaction engine.

Root cause: The old restoreCode wrapper no longer had callers.

Fix: Remove the dead wrapper and its fmt import.

Verification: golangci-lint run --timeout=5m; go test ./internal/control.
2026-08-04 01:46:48 +08:00
SivanCola 416eae24ad fix(frontend): isolate lazy settings stylesheet
Problem:
The direct TSX frontend tests failed when SettingsPanel imported its code-split CSS because Node does not load CSS modules.

Root cause:
The production-only stylesheet side effect lived in a reusable component module that unit tests and SubagentsPanel import directly.

Fix:
Add a lazy production entry that owns the stylesheet import while keeping SettingsPanel reusable, and lock the split with the bundle contract test.

Verification:
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend build
- focused SettingsPanel import tests
- git diff --check
2026-08-04 01:44:24 +08:00
SivanCola d25f7833c3 fix: make local rewind recovery atomic
Problem: Combined rewind recovery could leave conversation and files split after a crash, background writers could cross the preview/commit window, and ancestor symlinks could redirect checkpoint I/O outside the workspace.

Root cause: Startup recovery had no conversation applier, writer registration was not coupled to the mutation barrier, compatibility rewind bypassed the transaction engine, and filesystem operations relied on lexical containment checks.

Fix: Defer combined recovery until resume, restore conversation and checkpoints before file compensation, route legacy rewind through transactions, hold writer barriers across direct and fleet background agents, and use no-follow workspace-relative filesystem operations.

Verification: go test ./...; go test -race ./internal/checkpoint ./internal/control ./internal/agent; desktop go test ./...; Windows checkpoint cross-compile; frontend rewind contract test; cache guard; go vet focused packages.
2026-08-04 01:43:25 +08:00
HaoyueQin 2f5d44956b fix(desktop): stop subagent override cards overflowing the settings column
The overrides grid used repeat(2, minmax(200px, 1fr)) with a 568px
minimum, but the settings content column is only ~500-568px wide at
window sizes 760-930px, clipping the right edge of the cards. Switch the
two columns to minmax(0, 1fr) so they shrink with the column; the
existing <=760px single-column fallback stays in place.
2026-08-04 01:41:55 +08:00
HaoyueQin 7bbacfde21 fix(desktop): keep memory workspace selector and suggestions button on the same row
At <=900px the memory tab row stacked vertically, leaving the project-name
selector top-left and the Suggestions button bottom-right; with a wrapping
flex row the short name could also end up beside the memory tabs while the
button dropped to its own line. Group the workspace selector and the
Suggestions button in a dedicated .memory-tabs-row__tail container whose
100% flex-basis always takes the second line, so the two always share a
row regardless of name length. The name prefers one line and wraps
naturally (overflow-wrap:anywhere, no truncation) when space runs out.
2026-08-04 01:41:55 +08:00
HaoyueQin 8f5eef019c fix(desktop): make settings subtab pills shrink-wrap on narrow windows
The narrow-window media query forced .settings-subtabs to display:flex;
width:100% with horizontal scrolling, so the Usage/Access/Stats pills
stretched across the whole row. Switch to inline-flex with wrap so the
pills shrink-wrap their content and wrap onto further lines instead of
stretching or scrolling. This also gives the memory page tab pills the
same compact behavior.
2026-08-04 01:41:54 +08:00
SivanCola bc7a8b68b4 Merge pull request #7353 from SivanCola/fix/system-prompt-file-resilience-secure
fix(config): secure system prompt file fallback / 安全修复系统提示词文件降级
2026-08-04 01:36:01 +08:00
SivanCola ef870e951a Merge pull request #7360 from SivanCola/fix/docs-codeblock-layout
Fix documentation code block layout / 修复文档代码块布局
2026-08-04 01:32:54 +08:00
SivanCola a5ddf58c12 fix(site): restore documentation code block layout
Problem:
Astro 7 collapsed source newlines in documentation code blocks, concatenating commands and comments and allowing copy controls to obscure code.

Root cause:
The site relied on preformatted source whitespace while JSX-style HTML compression removed line breaks. Copy buttons also shared the code text area.

Fix:
Preserve intentional documentation whitespace, reserve a dedicated top area for every copyable code block, and add regression contracts for both behaviors.

Verification:
- cd site && npm test
- cd site && npm run build
- Browser geometry at 1280x720 and 390x844 across all 15 documentation code blocks
2026-08-04 01:29:20 +08:00
tic-top 79e4d1a7a8 docs(acp): document session/set_config_option's parameters
The Session controls table names the method and the option ids, but never the
request shape, so a client has to guess the field name. The natural guess is
optionId (it is setting an option, and the ids come from configOptions), and
that fails in a way that points at the wrong thing: the handler answers

    -32602 session/set_config_option: unknown config option

with an empty name interpolated at the end, which reads as "that option does not
exist" rather than "you named the parameter wrong". The option is in fact
advertised correctly in session/new's configOptions.

Cost when a client gets this wrong is not cosmetic: tool_approval silently stays
at 'ask', so an unattended host blocks on session/request_permission for every
gated call instead of running.

Documents the params in both language versions, with a worked example, an
explicit note that the field is configId, and the result/error shapes.
2026-08-03 17:26:36 +00:00
SivanCola faa634b769 Merge pull request #7351 from SivanCola/fix/bot-recovery-lease-handoff
Keep bot recovery session ownership aligned / 保持 Bot 恢复会话所有权一致
2026-08-04 01:21:19 +08:00
SivanCola aae3851a5c Merge pull request #7328 from Nath-Vikky/codex/fix-custom-supported-efforts
fix(openai): honor custom efforts for generic and DeepSeek endpoints
2026-08-04 01:16:43 +08:00
SivanCola ee278d86db Merge pull request #7210 from jackie-cqz/fix/terminal-theme
fix: add configurable terminal theme
2026-08-04 01:11:28 +08:00
SivanCola aeb28447ff fix(config): remove obsolete runtime merge wrapper
Problem:
The snapshot refactor left an unreferenced compatibility wrapper, which failed the repository unused-code lint gate.

Fix:
Remove the dead wrapper and update its documentation reference to the snapshot function.

Verification:
- golangci-lint v2.12.2 run --timeout=5m
- go test -race ./internal/config ./internal/boot -count=1
- ./scripts/cache-guard.sh
2026-08-04 01:04:37 +08:00
SivanCola 4721c321aa fix(config): bind prompt provenance to TOML snapshot
Problem:
Project prompt provenance was detected by rereading reasonix.toml separately from the merge, so concurrent replacement could pair a project value with trusted-user provenance.

Root cause:
Metadata and decoded values came from different filesystem snapshots.

Fix:
Read each TOML source once, validate and merge those bytes, and derive provenance metadata from the same immutable snapshot. Add a deterministic replacement regression.

Verification:
- go test ./internal/config ./internal/boot -count=1
- go test -race ./internal/config ./internal/boot -count=1
- ./scripts/cache-guard.sh
- go vet ./...
- go test ./... -count=1
2026-08-04 01:00:18 +08:00
OpenAI CodexandSivanCola d231c86934 Merge latest main-v2 session title updates
Integration:
- Synchronize PR #7328 with the latest main-v2 session title changes.
- Preserve the custom supported_efforts provider contract unchanged.

Verification:
- go test -count=1 ./internal/provider/openai ./internal/config ./internal/boot ./internal/agent ./internal/serve
- go test -race -count=1 ./internal/provider/openai
- ./scripts/cache-guard.sh
- go vet ./...
- go build ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:58:30 +08:00
SivanCola 0fdcc8a63a fix(checkpoint): avoid capacity hint overflow
Problem:
CodeQL reported a potential integer overflow in the checkpoint bounds map allocation.

Root cause:
The capacity hint added one to a slice length before allocating the map.

Fix:
Use the completed-checkpoint count as the hint and let the map grow for the optional current checkpoint.

Verification:
golangci-lint and checkpoint/control tests pass.
2026-08-04 00:55:53 +08:00
SivanCola ee41d1b88d fix(bot): serialize recovery handoff with teardown
Problem:
A recovery callback captured before gateway shutdown could run after closeSessionState released its lease and reacquire a recovery-path lease on an already retired session state.

Root cause:
Recovery lease transfer and session teardown had no shared lifecycle gate. The handler moved the SessionLeaseKeeper before checking whether the state was still live.

Fix:
Serialize recovery ownership handoffs with state retirement, reject callbacks that arrive after retirement, and cover the late-callback ordering with a deterministic regression test.

Verification:
- go test ./internal/bot ./internal/control ./internal/agent
- go test -race ./internal/bot
- go vet ./...
- go test ./...
2026-08-04 00:52:02 +08:00
SivanCola 3de328c1bf fix(rewind): satisfy static analysis checks
Problem:
The CI lint job rejected empty branches, a manual slice copy, and obsolete rollback helpers.

Root cause:
The transactional rewind replacement left legacy helpers and no-op error branches behind.

Fix:
Remove dead legacy paths, use copy for checkpoint snapshots, and surface undo fingerprint errors explicitly.

Verification:
golangci-lint v2.12.2 reports 0 issues; focused checkpoint, control, agent, and Windows cross-compile checks pass.
2026-08-04 00:51:38 +08:00
SivanCola f24249f4f1 Keep terminal chrome aligned with its theme
Problem:
- Auto mode referenced undefined app tokens, and explicit terminal themes left header controls and drawer borders on the app palette.

Root cause:
- Terminal colors were applied to xterm and part of the drawer, but higher-specificity layout rules and toolbar controls still bypassed the terminal token set.

Fix:
- Map auto mode to defined app tokens and route all terminal chrome through terminal-owned surface, border, foreground, and accent values.
- Set the native control color scheme for explicit light and dark terminal modes.
- Add CSS ownership regressions for the component and real drawer selectors.
- Raise the raw initial asset budget by the measured 1 KiB needed for the completed chrome rules.

Verification:
- pnpm --dir desktop/frontend test:terminal
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
- Edge computed-style matrix for auto light/dark and opposite explicit terminal themes
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:46:48 +08:00
SivanCola 6a2a84e039 feat(rewind): make local rollback transactional and conflict-safe
Problem:
Local rewind could partially restore files, silently fall back to legacy behavior, or apply a stale conflict decision.

Root cause:
Checkpoint payloads lacked a durable transaction protocol, mutation writers were not fenced consistently, and preview tokens were not revalidated at commit time.

Fix:
Add compatible v2 checkpoints, blob-backed atomic transactions with crash recovery, writer observation and barriers, controller prepare/commit/undo APIs, and immediate Desktop rewind with explicit conflict handling.

Verification:
Run root and Desktop Go suites, race tests, cache guard, Windows checkpoint cross-compile, frontend production build and targeted rewind/workspace contracts.
2026-08-04 00:43:48 +08:00
SivanCola 58de476bc8 Merge pull request #7349 from SivanCola/fix/serve-session-title-resilience
Fix resilient Serve session titles / 修复 Serve 会话标题生成与缓存
2026-08-04 00:41:55 +08:00
OpenAI CodexandSivanCola 9ec81f7099 Merge latest main-v2 effort contract
Integration:
- Incorporate PR #7350 declared DeepSeek effort vocabulary baseline.
- Retain PR #7328 generic OpenAI-compatible custom effort handling and authoritative explicit-list validation.

Verification:
- go test -count=1 ./internal/provider/openai ./internal/config ./internal/boot
- go test -race -count=1 ./internal/provider/openai
- ./scripts/cache-guard.sh
- go vet ./...
- go build ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:41:11 +08:00
YHH 2b5b709b91 Merge pull request #7350 from esengine/fix/explicit-supported-efforts
fix(provider): let a declared effort vocabulary outrank the built-in one
2026-08-04 00:34:20 +08:00
YHH 778a443e19 Merge pull request #7343 from esengine/refactor/unify-git-invocation
refactor(git): build every internal git invocation in one place
2026-08-04 00:24:08 +08:00
SivanColaandSprayHank 2f16450e39 fix(config): secure system prompt file fallback
Problem:
A stale system_prompt_file could abort startup, but the initial fallback design allowed project config to probe Reasonix home and downgraded every read failure. That could expose credential files to provider prompts or run without explicitly configured policy.

Root cause:
Merged configuration did not retain whether system_prompt_file came from user or project scope, and boot could not distinguish a genuinely missing file from containment, permission, or other I/O failures.

Fix:
Preserve prompt-file provenance, confine project-selected paths to the workspace with os.Root, keep the trusted user-home fallback, and fall back only when every allowed location is absent. Add config and boot regressions for source precedence, credential isolation, path and symlink escapes, mixed read errors, and startup behavior.

Verification:
- go test ./internal/config ./internal/boot -count=1
- go test -race ./internal/config ./internal/boot -count=1
- go vet ./...
- ./scripts/cache-guard.sh
- go test ./... -count=1

Co-authored-by: SprayHank <[email protected]>
2026-08-04 00:23:30 +08:00
SivanCola 3c86a352c5 fix(bot): keep recovery session ownership aligned
Problem:
Bot controllers could switch to an automatic recovery branch while their session lease and remote mapping still pointed at the original transcript. A later turn could then continue with inconsistent ownership or route an attached chat back to the stale path.

Root cause:
The bot created its SessionLeaseKeeper after boot.Build and never installed an OnSessionRecovered ownership handoff. Recovery therefore updated only the controller path.

Fix:
Create the lease keeper before controller construction, wire the failure-atomic recovery handoff, move attached runtime mappings only for the active session generation, and remember the recovered path for the host. Late callbacks from retired controllers cannot overwrite their replacements.

Verification:
- go test ./internal/bot ./internal/control ./internal/agent
- go test -race ./internal/bot
- go test ./...
2026-08-04 00:20:09 +08:00
SivanCola 0c5e4fdb65 Merge pull request #7334 from GOGV/fix/serve-delete-session-feedback
fix(serve): surface session-delete failures in the web UI
2026-08-04 00:19:32 +08:00
esengine 12fdaa830b fix(provider): let a declared effort vocabulary outrank the built-in one
A third-party endpoint can serve a DeepSeek model under its own effort
scale. Declaring supported_efforts is how the config states that, and it
is the only way to describe such an endpoint — but the DeepSeek thinking
branch validated the selected level against our built-in vocabulary and
rejected anything outside low/high/max/disabled, ignoring the
declaration entirely.

The rule already existed one case up: "low" is accepted on a non-flash
model precisely when supported_efforts declares it. This extends the
same deference to the rest of the vocabulary instead of applying it to
one level.

Undeclared levels are still rejected, and a declaration that does not
list the requested level is still a rejection — honoring the config
means honoring what it actually says.

Fixes #7273.
2026-08-03 09:18:32 -07:00
SivanCola 1c3c93752d Merge pull request #7348 from SivanCola/test/credit-pr-7308-cli-continue
test(cli): preserve bare -c routing regression / 保留裸 -c 路由回归覆盖
2026-08-04 00:14:27 +08:00
YHH 68d72e6e5f Merge pull request #7346 from esengine/fix/rewind-prompt-strip
fix(rewind): show the typed prompt, not the composed turn
2026-08-04 00:14:08 +08:00
SivanCola e235647a39 Harden terminal theme readability and saves
Problem:
- Light ANSI white and bright colours were unreadable on the light terminal surface.
- Concurrent Wails settings calls could persist an older click after newer user intent.

Root cause:
- The light palette retained colours intended for a dark terminal.
- Terminal-theme writes were fired independently without ordering or stale-refresh protection.

Fix:
- Use a WCAG-readable light ANSI palette and cover all 16 colours.
- Serialize terminal-theme persistence, protect optimistic state from stale reloads, and recover failed saves.
- Keep missing settings payloads backward-compatible.
- Raise only the raw initial-asset budget by the measured 4 KiB feature cost; gzip and per-chunk limits remain unchanged.

Verification:
- Node 24 frontend production build
- terminal and usage-stats frontend suites
- root and desktop Go suites
- focused race tests for config and desktop settings
- in-app browser terminal-theme switching check
2026-08-04 00:11:53 +08:00
a3358ef847 fix(serve): make session titles fast and stable
Problem:
Serve session titles can expose pasted-text or injected-language markers, return empty output when reasoning consumes a tiny token budget, and regenerate whenever an active transcript changes mtime.

Root cause:
The title provider still used the retired effort=off value, title inputs retained UI-only labels, and cache identity tracked the whole file timestamp instead of the first user message that determines the title.

Fix:
Disable reasoning for the dedicated title provider, use a clearer bounded title prompt, strip leading pasted-text labels, skip synthetic reasoning-language turns, and persist a backward-compatible SHA-256 fingerprint of the normalized first message.

Verification:
go test ./...
go test -race ./internal/serve ./internal/agent
go vet ./internal/serve ./internal/agent
./scripts/cache-guard.sh
git diff --check

Co-authored-by: SprayHank <[email protected]>
Co-authored-by: Q1hangL <[email protected]>
2026-08-04 00:10:52 +08:00
SivanCola ad972af1f9 Merge latest main-v2 into terminal theme fix
Integration:
- Preserve the latest transient-tag normalization changes.
- Preserve Workbench listener cancellation and its regression coverage.

Verification:
- The merged upstream files match origin/main-v2 exactly.
- git diff --cached --check passed.
2026-08-04 00:10:18 +08:00
YHH 35db95a66e Merge pull request #7345 from esengine/fix/transient-tag-single-source
fix(agent): derive transient-block handling from one tag list
2026-08-04 00:09:10 +08:00
SivanColaandSKY ZHAO 61f6e2cad0 test(cli): cover bare continue shorthand routing
Preserve the top-level routing regression coverage contributed in PR #7308 after the underlying flag registration fix landed through PR #7223.

Verification:
- go test ./internal/cli -run TestRunRoutesBareInteractiveFlagsToSession -count=1
- go test ./internal/cli -count=1
- git diff --check

Co-authored-by: SKY ZHAO <[email protected]>
2026-08-04 00:07:49 +08:00
esengine 7d9e0d35af test(desktop): assert probe properties, not the whole argument list
The workspace probe test pinned git's full argument list byte-for-byte,
so it restated the shared invocation baseline instead of the two
settings it actually cares about — and broke as soon as the baseline
gained the Windows long-path entry. That brittleness is part of why the
baseline drifted between call sites: touching it meant touching exact-
match tests that had nothing to do with the change.

It now asserts what the test is about — the daemon-spawn settings are
present, the caller's arguments are preserved, the console is hidden on
Windows — and leaves the baseline itself pinned by internal/gitcmd.
2026-08-03 09:05:59 -07:00
OpenAI CodexandSivanCola 952e4ac350 Merge main-v2 into custom effort fix
Integration:
- Synchronize PR #7328 with the latest main-v2 provider and CI changes.
- Preserve the custom supported_efforts validation contract across the merged provider updates.

Verification:
- go test -count=1 ./internal/provider/openai ./internal/config ./internal/boot
- go test -race -count=1 ./internal/provider/openai
- ./scripts/cache-guard.sh
- go vet ./...
- go build ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-04 00:01:45 +08:00
SivanCola 8b9674f460 Merge pull request #7321 from Nath-Vikky/codex/fix-workbench-context-cancel
fix(remote): stop Workbench listener on context cancellation
2026-08-04 00:00:27 +08:00
esengine 89e8c3d543 fix(rewind): show the typed prompt, not the composed turn
A checkpoint opens with the composed turn, so its stored prompt carries
whatever the controller prepended — the plan-mode marker and the
transient blocks. Nothing stripped that back out on the way to a
consumer, and every consumer treats Prompt as a human label: the TUI
rewind picker, the desktop change list, the workbench projection. The
rewind picker additionally restores the prompt into the composer, so
selecting an entry pasted controller markup into the user's input.

Users saw a rewind list whose entries all read "必须使用简体中文…" —
the reasoning-language block's first line, repeated for every turn,
with the actual prompts invisible behind it.

Checkpoints() now reduces each Prompt through StripComposePrefixes, the
existing helper that already knows about both the marker and the
transient blocks. Stripping on read rather than only on write also fixes
sessions whose checkpoints are already on disk, recorded composed.

Fixes #6903.
2026-08-03 09:00:12 -07:00
SivanCola 953b98c23a Merge main-v2 into terminal theme fix
Integration:
- Preserve the terminal theme startup field and terminal regression suite.
- Keep the latest config-warning startup contract, usage-stat tests, and hook linting from main-v2.

Verification:
- Conflict markers removed.
- git diff --cached --check passed.
2026-08-03 23:57:16 +08:00
SivanColaandSivanCola 6b31b35671 test(serve): guard session delete failure feedback
Contract: The embedded web UI must keep localized active-session feedback, surface non-2xx response text, and report rejected delete requests.

Coverage: Add a focused index contract test for both locales and all three delete failure branches.

Verification: go test ./internal/serve; go test -race ./internal/serve; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-03 23:54:49 +08:00
SivanColaandSivanCola 8ddaad8b9a fix(serve): report session delete network failures
Problem: Session deletion still failed silently when the browser request rejected before an HTTP response was available.

Root cause: The UI handled non-2xx responses but did not handle Promise rejection from fetch or response-body reads.

Fix: Add localized fallback feedback for rejected delete requests while preserving the existing active-session and HTTP-error paths.

Verification: Focused serve tests and browser checks cover the active-session guard, HTTP errors, and dropped connections.

Co-authored-by: SivanCola <[email protected]>
2026-08-03 23:54:40 +08:00
esengine 865adc6f1d fix(agent): derive transient-block handling from one tag list
The set of blocks the host prepends to a user turn was written out three
times: twice inside the strip regex (once for the opening alternation,
once for the closing one) and again as a switch in hasLeadingInjectedBlock.
The copies had already diverged — <autoresearch-runtime> was in none of
them, and the switch was missing active-goal, capability-route, and
interrupted-turn-recovery as well.

Two user-visible consequences. Markup for a missing tag survived into
previews, session titles, and the rewind picker, since the strip pass
never matched it. And hasLeadingInjectedBlock stopped its walk at the
first block it did not recognize, so an already-present response-language
or reasoning-language block behind one went undetected and a second copy
was prepended.

TransientUserBlockTags is now the one list: the regex is generated from
it and the walk iterates it. Attribute-carrying open tags (hook-context,
capability-route) are matched through a shared helper instead of ad-hoc
prefix checks. Tests iterate the list, so a tag added later is covered
without touching them.

Fixes #6534.
2026-08-03 08:52:45 -07:00
esengine 674be729bb refactor(git): drop computed allocation hints in argument building
Capacity hints derived from input lengths tripped the allocation-overflow
analyzer for no benefit — these lists hold a handful of entries. Uses
slices.Contains for the flag check while here.
2026-08-03 08:51:43 -07:00
SivanCola 0793472e30 Harden recovery trash across versions
Problem:
An older Reasonix runtime does not recognize the new recovery-trash cleanup operation and could route an interrupted marker through its ordinary delete reconciler before the transcript reached the trash.

Root cause:
The cleanup marker was durable before the primary transcript was recoverable, so cross-version reconciliation could observe an unsafe intermediate state.

Fix:
Write Desktop-compatible trash metadata and atomically move the transcript first, then publish the cleanup marker used to finish moving sidecars. Older runtimes can now delete only redundant leftovers after the conversation is already recoverable.

Verification:
- focused internal/agent recovery trash tests
- focused internal/agent race tests
- explicit cross-version publication-order regression test
2026-08-03 23:49:33 +08:00
SivanCola 0d41d616b3 Fix CLI recovery session hygiene
Problem:
CLI conflict-recovery copies can accumulate in resume lists, where they are indistinguishable from the original conversation and may be selected arbitrarily.

Root cause:
The CLI flattened session metadata, ignored recovery lineage, and never invoked the conservative recovery cleanup used by Desktop.

Fix:
Group recovery families in CLI resume surfaces, label copies with their parent, prefer the newest visible leaf, and move only covered, untouched, idle, unleased copies into the compatible session trash. Hold parent and branch removal guards across final revalidation and make interrupted trash moves durable.

Verification:
- go test ./...
- go vet ./...
- focused go test -race for agent recovery and CLI resume ordering
- ./scripts/cache-guard.sh
- Desktop recovery/session focused tests
2026-08-03 23:46:52 +08:00
esengine 129caed9e8 refactor(git): build every internal git invocation in one place
Reasonix shells out to git from five places: the CLI status readout, the
desktop workspace probe, the remote workbench, worktree management, and
plugin source checkouts. Each one assembled its own argument list, so the
invocation baseline drifted — three sites disabled fsmonitor and auto
maintenance, two did not, and the environment differed at every site.

internal/gitcmd now builds them all. Call sites keep their own
preferences (core.quotepath for the workbench, core.autocrlf for plugin
checkouts) by layering config on top of the shared baseline rather than
replacing it, and the environment is uniform: credential variables
filtered out of git subprocesses, optional locks off, no interactive
credential prompts on a terminal the TUI owns.

Also folds in the settings that keep repository-supplied configuration
from selecting a program for git to run during inspection — diff-side
external and textconv drivers alongside the existing fsmonitor and
maintenance settings — since a repository's config is authored by
whoever produced the repository, not chosen by the user. Covered by a
test that inspects a repository configured that way and asserts nothing
ran; the package comment records which drivers this cannot reach.

Argument-construction tests move from internal/worktree to the new
package with the code they cover.
2026-08-03 08:45:38 -07:00
SivanCola 5a5b50a985 Merge pull request #7335 from SivanCola/fix/session-event-replay-budget
Bound session event-log replay / 限制会话事件日志重放
2026-08-03 23:35:36 +08:00
YHH 3062ab652f Merge pull request #7341 from esengine/fix/security-hardening-5550-5551
fix(security): harden verification links and session lineage paths
2026-08-03 23:35:26 +08:00
esengine f7c693788c fix(security): harden verification links and session lineage paths
Two hardening fixes from the security triage:

- accounts worker: build email verification links from the configured
  APP_ORIGIN instead of the request origin — the Host header is
  caller-controlled, so a request-derived origin could point emailed links
  at an attacker-chosen host. Tokens are now URL-encoded too. (#5550)

- subagent store: session lineage walks (sessionAncestors,
  isAncestorSession) now route every session id — caller-provided and
  read back from branch metadata — through parentSessionPath's
  bare-filename validation instead of joining paths directly, so a
  "../"-shaped id can no longer escape the session directory. (#5551)
2026-08-03 08:17:53 -07:00
YHH 11d5462705 Merge pull request #7340 from esengine/feat/anthropic-web-search
feat(provider): server-side web_search passthrough for Anthropic-kind providers
2026-08-03 23:14:42 +08:00
SivanCola 7547d38c59 Harden nested session replay budgets
Problem:
A single encoded message could still expand into very large nested slices while staying below the byte and top-level message limits. User-message history loading also swallowed event replay refusals and returned an older checkpoint.

Root cause:
Replay preflighted only the messages array boundary before constructing provider.Message values, and LoadSessionUserMessages treated every probe or replay failure like a foreign event log.

Fix:
Token-walk each message before typed decoding, cap all nested JSON collection elements across the live transcript, and reject over-limit elements before reading them. Propagate probe, schema, and replay errors from user-message loading while retaining checkpoint fallback for safely ignorable logs.

Verification:
go test ./... -count=1
go test ./internal/agent -count=1
go test -race ./internal/agent -count=1
go vet ./...
cd desktop && go test ./... -count=1
cd desktop && go vet ./...
REASONIX_RELEASE_CACHE_GUARD=1 go test ./internal/agent -run ^TestReleaseCacheHitGuard$ -v -count=1
2026-08-03 23:11:43 +08:00
SivanCola 5e3712f60e Merge pull request #7319 from Li-Charles-One/feat/creation-windows-titlebar-height
fix(creation): Windows 标题条压至 40px 并对齐控件
2026-08-03 23:06:29 +08:00
SivanCola 4bd353eb31 Merge pull request #7238 from HaoyueQin/feat/usage-statistics
feat(stats): add usage statistics panel with heatmap, trends and model usage / 新增含热力图、趋势与模型用量的使用统计面板
2026-08-03 23:05:52 +08:00
SivanCola e125de6f07 Merge pull request #7320 from Li-Charles-One/fix/settings-permission-rule-overflow
fix(desktop): 细粒度权限规则长命令不再溢出(Fixes #7315)
2026-08-03 23:01:43 +08:00
esengine 0e59991860 test(anthropic): pin web_search wire shape and result surfacing
Follow-up hardening for the web_search passthrough:

- formatWebSearchResults: drop the stray leading newline when every result
  entry is blank, and use Fprintf per lint.
- Pin the anthTool wire shape both ways: the typed server tool must omit
  input_schema, and omitempty must not strip the schema from named tools.
- Cover buildRequest tool ordering with web_search on/off.
- Full SSE round-trip test: web_search_tool_result surfaces as readable
  text and server_tool_use is never mistaken for a client tool call.

Capability verified live against api.deepseek.com/anthropic: the endpoint
executes searches server-side and meters them in usage as
server_tool_use.web_search_requests.
2026-08-03 07:57:01 -07:00
Sean Liao 1f9e09c736 feat: support for anthropic web_search
Adds support for calling web_search via the provider.

Example provider config, using DeepSeek's support for the web_search
tool:

```toml
[[providers]]
name        = "anthropic-api-deepseek-com"
kind        = "anthropic"
base_url    = "https://api.deepseek.com/anthropic"
models      = ["deepseek-v4-flash", "deepseek-v4-pro"]
api_key_env = "DEEPSEEK_API_KEY"
context_window = 128000   # tokens; compaction triggers near this limit
prices      = { "deepseek-v4-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } }   # per-model prices, per 1M tokens
web_search  = true   # enable server-side web_search tool (Anthropic/DeepSeek API)
```

Example use, note that it doesn't try to perform the search via a call
to a search engine:

```
◆ reasonix  · deepseek-v4-pro
  Context is kept across turns. Type 'exit' or Ctrl-D to quit.

  › search the web for hello

  ▎ thought for 1s
• Hello Definition & Meaning | Britannica Dictionary
  https://www.britannica.com/dictionary/hello?srch_tag=e2cdp6pptoimak7ydbp6kjxddhwqysz3
• hello https://baike.baidu.com/item/hello/38467
• hello _ 百科 https://baike.sogou.com/v64731937.htm?addressbar=hide&jump_from=undefined&vrid=81001007
• hello
  https://baike.sogou.com/v64731937.htm?ch=frombaikevr&fromTitle=hello&vrid=30010097&entryScene=baike_VR_ruo&j
  ump_from=sogou-mobb-e447193f2b83d789&addressbar=hide#paragraph_3
• hello http://dict.youdao.com/w/hell_o/
• hello是什么意思 https://www.hanzidaquan.com/yingyu/hello.html
• Hello! https://www.ldoceonline.com/dictionary/hello
• hello是什么意思? https://wenwen.sogou.com/z/q961078741.htm
• hello是什么意思,hello怎么读,hello的意思是:“喂”的招呼声或问候 https://fanyi.kkabc.com/ci/Hello
• hello https://www.wordreference.com/enzh/hello Here are the top results from the web search for "hello":

1. Britannica Dictionary — Defines "hello" as a greeting, a telephone answer, a way to get attention, and an
   expression of surprise. Pronunciation: /həˈloʊ/.
2. Baidu Baike (百度百科) — Covers the history: derived from Old High German "halâ", popularized by Edison
   with the telephone. Notes its use in Chinese as "你好".
3. Sogou Baike (搜狗百科) — Similar to the Baidu entry, covering meanings, usage, and etymology including the
   Edison telephone story.
4. Youdao Dictionary — Lists: interjection (greeting, telephone, getting attention), noun (a greeting), and
   verb (to say hello). Also notes "Hello" as a song by Karmin.
5. Longman Dictionary — Comprehensive entry with usage examples: greeting, telephone, attracting attention,
   expressing surprise/confusion. Also covers informal alternatives (hi, hey, hiya).
6. WordReference — Bilingual English-Chinese entry with translations, compound forms ("golden hello", "say
   hello"), and pronunciation guides.

If you had something more specific in mind, let me know and I can refine the search!

  ▎ thought for 0s
  · 33233 tok · in 32870 (12800 cached / 20070 new) · out 363 · ¥0.0627
```

For #4217
For #5166
2026-08-03 07:52:24 -07:00
SivanCola f3490cd370 Merge latest main-v2 updater fix
Integration:
- Sync the usage-statistics branch with the latest Desktop updater recovery fix.
- Preserve the statistics subtab and updater settings behavior in the shared SettingsPanel and locale files.

Verification:
- (cd desktop/frontend && pnpm test:updater)
- (cd desktop/frontend && pnpm test:usage-stats)
- (cd desktop/frontend && pnpm typecheck && pnpm build)
2026-08-03 22:49:35 +08:00
SivanCola ac493a7a52 fix(session): enforce replay limits before allocation
Problem:
Session event replay decoded an entire messages array before enforcing its count budget, and the ownership probe treated JSON field order as a persistence contract.

Root cause:
The replay decoder materialized []provider.Message in the outer event record, while the bounded probe required schema_version and type to be the first two keys.

Fix:
Keep message arrays as raw JSON until each element passes the aggregate message budget, and preserve order-independent header recognition for valid logs within the byte limit while retaining the bounded native fast path.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- focused agent and Desktop go test -race
- go vet ./internal/agent
- cd desktop && go vet .
- git diff --check
2026-08-03 22:38:04 +08:00
SivanCola 1d8c7a4223 fix(creation): scope compact titlebar to Windows
Problem: The Windows titlebar adjustment changed shared Creation topicbar geometry on macOS and Linux.

Root cause: The compact height, padding, and title offset were applied through platform-neutral .app--creation selectors.

Fix: Restore the shared Creation geometry and move the 40px alignment overrides behind Windows-specific selectors for both default and themed styles.

Verification: app-chrome-tabs.test.ts, CSS checks, TypeScript typecheck, and the production frontend build passed.
2026-08-03 22:36:40 +08:00
SivanCola 1e5c0674ea Merge pull request #7336 from SivanCola/fix/updater-recovery-fallback
fix(desktop): improve updater recovery UX and feedback / 改进更新恢复与反馈体验
2026-08-03 22:34:02 +08:00
SivanCola 1985115723 Merge latest main-v2 settings updates
Integration:
- Sync usage statistics with the newly merged compaction-settings and diagnostics changes.
- Preserve both SettingsPanel subfeatures and their Wails/localization contracts.

Verification:
- go test -timeout=10m ./internal/stats ./internal/cli ./internal/config
- (cd desktop && go test -timeout=10m .)
- (cd desktop/frontend && pnpm typecheck && pnpm test:usage-stats && pnpm build)
2026-08-03 22:29:21 +08:00
SivanCola 51b388d4cb fix(session): bound event log replay
Problem:
Native session event logs could be fully buffered and decoded during startup without byte, record, or message limits. Desktop also treated pinned-session load failures like a missing session and could open a blank replacement.

Root cause:
The ownership probe decoded the entire first event, replay trusted the complete log shape, and the pinned-session helper collapsed non-ENOENT load errors into a false result.

Fix:
Probe only the bounded native header, enforce replay budgets with a typed path-free error, and fail closed in Desktop while preserving session files and releasing partial runtime resources.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- focused agent and Desktop go test -race
- go vet ./internal/agent
- cd desktop && go vet .
2026-08-03 22:21:29 +08:00
SivanCola 91bba93103 fix(desktop): improve updater recovery fallback
Problem:
Desktop update failures could leave users with only a retry action, while the update settings mixed recovery actions with low-frequency preferences and lacked a direct feedback path.

Root cause:
The frontend reduced updater failures to a manual-fallback boolean and did not distinguish recoverable pending transactions from transient failures or manual-only installation environments.

Fix:
Classify updater errors as retryable, recovery, or manual; prioritize the official signed installer for blocked flows; add official download, changelog, and GitHub feedback actions; and move privacy/update preferences into a collapsed footer section.

Verification:
- pnpm --dir desktop/frontend test:updater (33 passed)
- pnpm --dir desktop/frontend build
- in-app browser DOM and computed-style acceptance
2026-08-03 22:19:35 +08:00
SprayHank 9549af0817 fix(serve): surface session-delete failures in the web UI
Deleting a session silently swallowed non-2xx responses (the 409
'cannot delete active session' guard), so the click appeared to do
nothing. The delete button on the active session now shows a localized
notice ('无法删除当前会话' / 'Cannot delete the active session') without
calling the API, and any other failure response shows the server's
error text in a notice.
2026-08-03 22:18:36 +08:00
SivanCola 32c5bff6f2 Merge pull request #7233 from SivanCola/feature/context-compaction-threshold
Expose configurable context compaction threshold in Desktop and CLI / 在桌面端和 CLI 配置上下文压缩阈值
2026-08-03 22:13:45 +08:00
SivanCola 5cb1a78394 Merge pull request #7327 from SivanCola/fix/diagnostics-hang-signal-quality
fix(diagnostics): improve hang signal quality / 提升卡顿诊断信号质量
2026-08-03 22:12:34 +08:00
SivanCola 1861115cd2 Merge main-v2 into usage statistics
Integration:
- Sync the contributor branch with the latest main-v2 before final review.
- Preserve the reviewed usage-statistics changes while incorporating current Desktop, Remote, CLI, and provider fixes.

Verification:
- go test -timeout=20m ./...
- (cd desktop && go test -timeout=20m ./...)
- targeted Go race tests for provider, stats, agent, guardian, Remote, and Desktop
- (cd desktop/frontend && pnpm build && pnpm test:all)
2026-08-03 22:10:36 +08:00
SivanCola 35cdd64dad perf(settings): keep compaction controls off initial bundle
Problem:
The Linux desktop CI build exceeded the initial raw JavaScript and CSS budget by 0.3 KiB after adding the compaction controls.

Solution:
Code-split the control-specific CSS with the lazy Settings panel, deduplicate preset copy, and simplify equivalent state handling and guidance without changing the interaction contract.

Verification:
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/typography-overflow-contract.test.ts
2026-08-03 21:55:39 +08:00
Vikky e36b6aa803 fix(openai): honor custom supported efforts 2026-08-03 21:48:58 +08:00
SivanCola 39c6820689 feat(cli): configure compaction threshold
Problem:
Desktop exposes the automatic compaction threshold, but CLI users and scripts cannot inspect its effective source or update it safely.

Solution:
Add compact-ratio query, user-global writes, and project-local overrides with the same 65-85% guard rails and transactional config locking. Document the behavior in English and Chinese CLI/spec docs, localized help, and the website.

Verification:
- go test ./...
- go test ./internal/cli ./internal/config
- ./scripts/cache-guard.sh
- npm --prefix site test
- npm --prefix site run build
2026-08-03 21:46:05 +08:00
SivanCola 691624d8da fix(desktop): remove obsolete WebView2 report wrapper
Problem:
The Linux desktop CI job failed its unused-code lint after the contextual WebView2 report builder replaced the old compatibility wrapper.

Root cause:
The wrapper no longer had production or test callers, but remained in wails_logger.go.

Fix:
Remove the dead wrapper and keep all call sites on the contextual report builder.

Verification:
golangci-lint v2.12.2 reports zero issues; go vet, all desktop Go tests, and Windows amd64 test-binary cross-compilation passed.
2026-08-03 21:44:30 +08:00
SivanCola a04460e738 fix(desktop): track WebView2 as a direct dependency
Problem:
The desktop CI go.mod tidy gate failed because the new Windows Runtime version helper imports go-webview2 directly while the module still classified it as indirect.

Root cause:
The implementation added the direct import without committing the dependency classification produced by go mod tidy.

Fix:
Move the existing go-webview2 requirement into the direct dependency block without changing its version, checksum, or local replacement.

Verification:
go mod tidy is stable; desktop Go tests, race tests, vet, and Windows amd64 test-binary cross-compilation passed.
2026-08-03 21:36:43 +08:00
SivanCola 33c3145665 fix(stats): count failed provider requests
Problem:
Usage statistics omitted HTTP attempts whenever a logical provider stream failed or ended without terminal token usage, so retries could appear as zero requests.

Root cause:
Providers owned the request counter, callers only emitted successful Usage records, and the statistics recorder rejected zero-token records.

Fix:
Reuse caller-owned attempt counters across built-in providers, synthesize request-only usage on failed or missing-usage streams, cover every direct model-call path plus the Remote broker, and persist those records without forwarding zero-token receipts to the UI. Aggregate request-only rows independently from model token distributions and preserve exact Desktop request totals.

Verification:
- go test -timeout=20m ./...
- go test -timeout=20m ./... in desktop
- go test -race on the affected root and Desktop packages
- go vet ./... in root and desktop
- pnpm typecheck
- pnpm test:usage-stats
2026-08-03 21:33:57 +08:00
SivanCola 3bdacd8d70 fix(settings): separate custom compaction editing
Problem:
- The four-option segmented control truncated every threshold label at the settings width.
- Opening custom editing visually replaced the saved preset selection before any value was applied.

Root cause:
- Preset values and the custom-editor disclosure shared one selected-state control.

Fix:
- Render the three presets as equal-width two-line choices and keep their full accessible labels.
- Move custom editing to a separate disclosure beside an explicit saved-value summary.
- Preserve the saved preset selection while the editor is open and cover the state contract with regression tests.

Verification:
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm exec tsx src/__tests__/typography-overflow-contract.test.ts
- pnpm typecheck
- pnpm check:css
- pnpm lint:hooks
- ./scripts/cache-guard.sh
- In-app browser DOM, computed-size, overflow, and disclosure-state checks
2026-08-03 21:33:48 +08:00
SivanCola b0d74fdf09 fix(diagnostics): improve hang signal quality
Problem:
Recent hang telemetry mixed lifetime diagnostic counts with selected time windows, allowed synthetic versions to become the release baseline, and lacked enough context to distinguish WebView2 failures from UI measurement or sleep/resume artifacts.

Root cause:
Diagnostic group queries ignored the dashboard window, long code viewers fed virtual-row measurements back through React, the watchdog retained a pre-sleep heartbeat, and WebView2 failures were queued without per-session throttling or runtime attribution.

Fix:
Apply diagnostic window filters and stable release quorum rules, show deduplicated affected installs, use direct DOM virtualizer updates, reset the watchdog after sleep, tier heap reports, and add throttled WebView2 reports with runtime and occurrence context.

Verification:
Desktop Go tests, frontend focused tests and typecheck, crash-report worker tests and typecheck, and Windows amd64 cross-compilation passed.
2026-08-03 21:14:15 +08:00
SivanCola 4c1b0aeb50 fix(settings): explain compaction threshold guard rails
Problem:
The custom compaction editor exposed a 65%–85% range without explaining why values outside it are unavailable.

Root cause:
The hint described only input mechanics and omitted the adjacent 60% tool-output cleanup and 90% forced-compaction watermarks.

Fix:
Explain both guard rails and their safety purpose in every supported locale, with regression coverage for the English contract.

Verification:
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm test:typecheck
- In-app browser DOM and wrapping checks
2026-08-03 20:59:31 +08:00
LI Xinyu c36ca274c3 fix(agent): skip corrupt subagent metadata during startup cleanup
CleanupStaleRunning aborted startup on any undecodable .meta.json
(truncated write or killed process), reporting "cleanup stale
subagents: decode subagent metadata ...: unexpected end of JSON
input" and blocking the whole app. Skip JSON decode failures the same
way ListSubagentsByParent already does, while keeping genuine I/O
errors fatal.

Fixes #7191
2026-08-03 20:45:11 +08:00
SivanColaandSivanCola 449c98f82e fix(stats): remove accounting stalls and align metrics
Problem:
Usage accounting could block turn completion on file locks, undercount retried or continued provider calls, label turns as sessions, and render oversized custom ranges.

Root cause:
Recording performed disk I/O inline; request attempts were not carried through provider retry, reconnect, continuation, and recovery merges; the UI reused session wording; range work scaled with absent days and the full trend.

Fix:
Queue records through one bounded per-state-dir worker with settings and shutdown flush boundaries, attach HTTP attempt counts through provider contexts, preserve counts across recovery, rename completed turns, scan only existing stats files, and cap trend DOM to the latest 180 days with a notice.

Verification:
- go test -race ./internal/stats ./internal/provider ./internal/provider/openai ./internal/provider/anthropic ./internal/provider/responses
- go test ./...
- cd desktop && go test ./... && go vet ./...
- cd desktop/frontend && pnpm test:usage-stats && pnpm typecheck && pnpm build

Co-authored-by: SivanCola <[email protected]>
2026-08-03 20:37:56 +08:00
SivanCola e6fa7e8314 fix(settings): keep custom compaction option visible
Problem:
The custom compaction threshold option rendered outside the fixed 320px settings control and was clipped by the segmented control overflow.

Root cause:
Segment buttons kept their intrinsic minimum width, so the fourth localized option could not shrink into the available column.

Fix:
Allow compaction options to share and shrink within the full control width, preserve truncated labels with titles, and add CSS contract coverage.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm build
- In-app browser boundary and interaction checks
2026-08-03 20:37:33 +08:00
SivanCola f8ea772bfa Merge pull request #7311 from SivanCola/fix/cli-flag-parse-errors
fix(cli): unify flag errors and remote connect ordering / 统一参数错误与远程连接顺序
2026-08-03 20:11:21 +08:00
SivanCola f5cfa5ca64 refactor(desktop): streamline compaction threshold controls
Product: Keep presets on the default path and reveal exact threshold input only on request.

Interaction: Add focus-managed editing, explicit apply and cancel actions, keyboard recovery, and failure-atomic saves.

Budget: Raise the raw initial asset ceiling by 2 KiB for the measured feature cost while leaving gzip and per-chunk limits unchanged.

Verification: pnpm build; pnpm test; pnpm test:typecheck; focused settings snapshot coverage; in-app browser interaction check.
2026-08-03 20:08:30 +08:00
YHH f76aad817d feat(telemetry): separate the 400s that need different fixes (#7316)
Every provider 400 lands in one bucket, so the dashboard says 46 of them
happened last month and nothing about which. Triaging four such reports by hand
this week turned up four unrelated causes: a replayed image_url, a tool schema
missing `required`, a thinking parameter shaped as an object where a boolean was
expected, and reasoning content that had to be echoed back. Each needs a
different fix, and none is distinguishable from the bucket.

errorClass deliberately never uploads the message itself, because a provider
body can echo request content back. That constraint stays: every arm matches a
fixed substring and returns a fixed token, so nothing from the body reaches the
label. Unrecognized shapes stay plain http_400 rather than guessing.

The user-facing message is unchanged — control.apiErrorReason already appends
the provider's verbatim reason, which is why those four were diagnosable at all.
This closes the gap on the telemetry side, where the aggregate lives.

Cache-impact: none. desktop/metrics_app.go is not on the cache-sensitive path
list in scripts/check-cache-impact.sh; no prompt or tool bytes change.
Cache-guard: not applicable for the same reason.

Verified: go test ./desktop (full module), plus a test asserting that bodies
carrying paths, filenames and an API key produce labels containing none of them.
2026-08-03 20:00:30 +08:00
Vikky 9847087ec5 fix(remote): stop Workbench listener on context cancellation 2026-08-03 20:00:13 +08:00
Li-Charles-One d3b10d04b4 fix(desktop): keep long permission rules inside chips (Fixes #7315)
Wrap long allow/deny command rules so the delete control stays visible
instead of overflowing the settings card.
2026-08-03 19:57:44 +08:00
SivanCola 9d403e91a7 Merge latest main-v2 into compaction settings
Integration: Bring the compaction threshold PR onto the current main-v2 baseline.

Resolution: Preserve the compaction presets while retaining the upstream GLM reasoning protocol option.

Verification: Conflict markers removed; focused feature verification follows in the next commit.
2026-08-03 19:55:52 +08:00
SivanCola af71a90ff7 fix(cli): finish successful help handling
Problem:
Explicit help still returned status 2 for nested commands whose positional arity was checked before FlagSet parsing, while successful FlagSet help was written to stderr.

Root cause:
The shared reporter reused the FlagSet error output and could only see help after Parse; remote add, remote serve, and named subagent commands short-circuited before that boundary.

Fix:
Route successful help to stdout, recognize help within required positional prefixes before arity validation, and expand end-to-end coverage for help and error streams.

Verification:
- go test ./... -count=1
- go test -race ./internal/cli -count=1
- go vet ./...
- scripts/check-cache-impact.sh on all touched CLI files
- git diff --check
2026-08-03 19:16:27 +08:00
SivanColaandSivanCola 90a8362bb4 fix(stats): complete usage accounting paths
Problem:
Remote Workbench usage was written on the SSH target, Guardian reviews kept only the last model call, external child models could be misattributed, and stale frontend requests could restore obsolete data.

Root cause:
Accounting followed the remote controller filesystem instead of the local broker, review usage used a last-value slot, identity lookup ignored the authoritative resolver catalog, and incomplete or failed filters did not invalidate main-panel generations.

Fix:
Persist brokered remote usage and turn completions in Desktop state, aggregate every Guardian call including compaction, resolve child and capability-router identities through the resolver, and clear/invalidate stats on every filter transition.

Verification:
- go test ./internal/guardian ./internal/boot ./internal/remote/workbench/runtime ./internal/stats
- go test ./... -run focused remote statistics tests (desktop)
- pnpm test:usage-stats
- pnpm typecheck

Co-authored-by: SivanCola <[email protected]>
2026-08-03 19:14:56 +08:00
SivanColaandskyzhao1223 fb9af7d3df fix(cli): accept remote connect flags around host name
Problem:
Go's flag parser stops at the first positional argument, so the documented remote connect <name> [flags] form rejected flags placed after the host name.

Root cause:
remoteConnectCLI parsed the complete argument tail as one standard-library FlagSet and required exactly one leftover positional value.

Fix:
Extract a focused remote connect syntax parser, accept both name-first and flags-first forms, preserve the -open and remote open aliases, and carry deferred parse errors through the shared command flag reporting contract.

Verification:
- go test ./...
- go test -race ./internal/cli -run 'Test(ParseCommandFlags|ParseRemoteConnectSyntaxFlagOrder|RemoteConnectSyntaxUsesSharedFlagContract)' -count=1
- go vet ./...
- git diff --check

Co-authored-by: skyzhao1223 <[email protected]>
2026-08-03 18:56:03 +08:00
SivanCola db3b4ccbcb fix(cli): report flag parsing errors consistently
Problem:
Malformed pflag arguments could exit with status 2 without explaining the error, while subcommand help was treated as a failure.

Root cause:
Command entry points handled ContinueOnError results independently. pflag does not emit parse errors in this mode, and neither flag implementation's help sentinel was distinguished.

Fix:
Add a shared parser wrapper for standard flag and pflag, route all direct CLI FlagSet parsing through it, and preserve upgrade parser purity while returning help successfully.

Verification:
- go test ./...
- go test ./internal/cli ./cmd/reasonix -count=1
- go vet ./...
2026-08-03 18:56:03 +08:00
SivanCola b36a7e3d54 Merge pull request #7313 from esengine/docs/review-v1.19.4-final
docs(release): refresh v1.19.4 notes / 补充 1.19.4 发布说明
2026-08-03 18:54:19 +08:00
SivanCola 4727e045d8 docs(release): refresh v1.19.4 notes
Cover the decision disclosure and conditional React Hook crash fixes that landed after the initial release-note review. Keep the stable record bilingual and bound to the newly reviewed Desktop behavior.
2026-08-03 18:53:17 +08:00
SivanCola 10a6d8c23a Merge pull request #7300 from SivanCola/fix/decision-long-text-disclosure
fix(desktop): disclose long decision descriptions / 完善长文本决策说明
2026-08-03 18:51:42 +08:00
SivanCola b74121b60f Merge pull request #7310 from SivanCola/fix/react-hook-order
fix(desktop): prevent conditional hook-order crashes / 防止条件 Hook 顺序崩溃
2026-08-03 18:48:58 +08:00
SivanCola fb8a8afec1 Merge pull request #7294 from esengine/release-notes/v1.19.4
docs(release): prepare v1.19.4 bilingual notes / 准备 v1.19.4 中英更新日志
2026-08-03 18:42:04 +08:00
SivanCola 0e2b986f11 fix(desktop): prevent conditional hook-order crashes
Problem:
ProviderEditor and WorkspacePanel could render fewer hooks behind early-return branches, leaving later state transitions vulnerable to React error #310.

Root cause:
Three provider memo hooks followed the built-in editor return, while workspace code-search hooks followed the closed-panel return. The frontend also had no automated rules-of-hooks gate.

Fix:
Keep every hook above the relevant early return, add built-in-to-custom and closed-to-open regression coverage, and run eslint-plugin-react-hooks during production builds.

Verification:
- pnpm lint:hooks
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm exec tsx src/__tests__/workspace-changes-errors.test.tsx
- pnpm test:typecheck
- pnpm test
- pnpm build
- git diff --check
2026-08-03 18:39:06 +08:00
SivanCola be830422cd Merge pull request #7307 from SivanCola/fix/e2e-fixed-suite-budget
fix(e2e): budget the complete fixed suite / 为完整固定套件校准预算
2026-08-03 18:38:05 +08:00
Li-Charles-One 78f955923a fix(creation): slim Windows title strip to 40px
Match window controls, topicbar, and dock tools height so caption buttons
are not taller than the title strip. Drop leftover vertical offsets.
2026-08-03 18:37:09 +08:00
SivanCola 2b16d4242b fix(stats): harden attribution and persistence
Problem:
Auxiliary model usage could be unattributed, empty stats responses exposed null arrays, torn JSONL tails consumed the next record, and top-provider ranking followed the top model.

Root cause:
The router and reviewer events omitted canonical model refs, the disabled query path returned nil slices, appends did not repair record boundaries, and provider ranking reused model ordering.

Fix:
Propagate canonical model refs through both auxiliary paths, centralize empty-array query responses, serialize appends under the existing cross-process file lock with torn-tail repair, and rank providers from aggregate totals. Add focused regression coverage for each contract.

Verification:
- go test -race ./internal/stats ./internal/capability ./internal/recovery
- go test ./...
- cd desktop && go test ./...
- go vet ./...
- cd desktop && go vet ./...
- cd desktop/frontend && npm test -- --run
- cd desktop/frontend && npm run build
2026-08-03 18:36:43 +08:00
SivanCola d22d871238 docs(release): sync v1.19.4 notes with main
Scope:
Bring the reviewed release-notes branch onto the latest main-v2 after the E2E runner repair, issue-label workflow fix, Token Rhythm provider integration, and CLI continuation shorthand repair.

Verification:
Release note validation and the exact-candidate CI will run again on the updated branch.
2026-08-03 18:28:02 +08:00
SivanCola d4fffb5cdf docs(release): include latest v1.19.4 changes
Scope:
Add the Token Rhythm provider and model-aware reasoning work from PR #7301, restore the CLI continuation shorthand fix from PR #7223, and credit the new contributor.

Review:
Refresh the bilingual title, summary, highlights, New section, and Fixed section without changing the reviewed Stable identity or migration guidance.

Verification:
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version 1.19.4 --output /tmp/reasonix-1.19.4-zh.md
git diff --check
2026-08-03 18:27:52 +08:00
SivanCola c6ddaab47a Merge pull request #7223 from Lxiny-zy/fix/cli-c-shorthand-flag
fix(cli): make -c shorthand resolve for --continue
2026-08-03 18:25:36 +08:00
SivanCola 2fec84022f merge: integrate latest main-v2 into decision disclosure fix
Integration:
Merge main-v2 at f63bee144 after the Token Rhythm, unattended E2E, desktop icon repair, and remote runtime fixes landed.

Conflict resolution:
Keep the 2256 KiB raw initial-asset ceiling from the decision-disclosure branch; the combined production build measures 2255.3 KiB. Preserve both the decision disclosure strings and the new Token Rhythm and GLM locale entries.

Verification:
Passed the full frontend test suite and the production build with all bundle budgets on the integrated tree.
2026-08-03 18:23:40 +08:00
SivanCola 5e18ab64ba fix(desktop): harden decision disclosure controls
Problem:
Keyboard activation of long-description disclosures bubbled into global decision shortcuts, which could submit the current Ask answer, approve a tool call, or run the clear-context default. Decision cards also capped body and actions independently, so their combined height could still exceed the viewport.

Root cause:
The shared disclosure button did not own its activation key events, and PromptShelf had no single viewport-bounded scrolling owner for the complete decision content.

Fix:
Handle Enter and Space inside the shared disclosure control, wrap prompt content in one bounded internal scroller, keep confirmation footers outside that scroller, and remove nested body/action scroll regions.

Verification:
Passed frontend typecheck, CSS checks, focused Ask and decision-surface tests, the full frontend test suite, production build and bundle budgets, plus an in-app browser long-text and keyboard interaction check.
2026-08-03 18:20:55 +08:00
zhaotian1 d7addef4d8 fix(agent): keep subagent list CreatedAt as start time
Subagent transcripts are first saved on completion, so BranchMeta.CreatedAt
was backfilled with the save moment. Seed the sidecar from run.Meta.CreatedAt
before Session.Save so history shows when the subagent started.
2026-08-03 18:17:57 +08:00
SivanCola f63bee1444 Merge pull request #7301 from SivanCola/codex/token-rhythm-provider-glm-effort
feat: add Token Rhythm provider with model-aware reasoning
2026-08-03 18:15:20 +08:00
SivanCola dfa11ead16 fix(e2e): budget the complete fixed suite
Problem:
The calibrated 400k-token cap was exhausted after three successful baseline tasks, so the final two scenarios were skipped and the release gate could never complete.

Root cause:
Current model and cache behavior uses roughly 425k tokens for the first three tasks while keeping provider cost low, but both the harness default and workflow override retained the older 400k limit.

Fix:
Raise the bounded fixed-suite budget to 800k in the harness and workflow, and add a regression contract for the five-task baseline. Failure or skipping still fails the workflow.

Verification:
go test ./cmd/e2ebench -count=5
go vet ./cmd/e2ebench
go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/e2e-bot.yml
bash scripts/release-workflows.test.sh
2026-08-03 18:14:05 +08:00
YHH c5c5557cf8 fix(ci): label issue version from the reported version, not the dropdown (#7292)
Closes #2549.

The dropdown is a choice and "v2 — Go rewrite (1.x)" sits first, so reporters
running 0.x pick it. `Exact version` is pasted from `reasonix --version`, so
when it parses it is the better evidence: 0.x is the TypeScript line, 1.x the
Go rewrite. A value that does not parse — a commit sha, "main-v2" — leaves the
dropdown to decide, since a wrong guess is worse than the status quo.

Replaying both rules over all 1161 open issues: 11 are labelled differently,
every one of them v2 -> v1, and every one currently carries the wrong label.
Reported versions on those range from 0.3.0 to 0.53.2. Nothing moves the other
way, so the cross-check only fires where the evidence is unambiguous.

This matters beyond tidiness: filtering the tracker by `v2` is how the backlog
gets triaged, and those 11 are legacy reports sitting in that filter.

The workflow only fires on opened/edited, so existing mislabels are corrected
separately.
2026-08-03 18:10:25 +08:00
SivanCola 15ed6bc8db Merge pull request #7303 from SivanCola/fix/e2e-unattended-writes
fix(e2e): allow unattended benchmark writes / 允许无人值守基准写入
2026-08-03 18:09:41 +08:00
SivanCola 4cfe58884d fix(desktop): adapt usage charts to active theme
Problem:
Model bars and donut segments used a fixed 20-color palette, so theme and base-style changes did not update them and several colors had insufficient light-theme contrast. The PR's browser mock also pushed the initial bundle just beyond its raw-size budget.

Root cause:
The palette was stored as literal final hex colors in React while the rest of the statistics visuals used semantic CSS variables. The empty browser mock included provider aggregates the panel does not consume.

Fix:
Blend stable model seed hues with the active accent and foreground, including overflow colors beyond the primary palette. Add regression coverage for primary and overflow theme adaptation, and keep the browser mock limited to fields the panel reads.

Verification:
pnpm test:usage-stats
pnpm typecheck
pnpm check:css
pnpm build
Browser: Graphite dark/light and Aurora light; no console errors
2026-08-03 18:08:59 +08:00
SivanCola b0f57300a1 fix: preserve GLM reasoning protocol in desktop settings
Problem: Desktop settings normalized an explicit GLM reasoning protocol to auto and could erase it when saving a provider.\n\nRoot cause: The frontend protocol whitelist and localized selector labels were not extended when the backend GLM enum was introduced.\n\nFix: Add GLM to the settings whitelist, selector labels, locale contracts, and provider-view type documentation. Cover normalization and locale availability with focused regressions.\n\nVerification:\n- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx\n- pnpm exec tsx src/__tests__/startup-settings-contract.test.ts\n- pnpm typecheck\n- pnpm build
2026-08-03 18:05:36 +08:00
SivanCola f18c137ff5 fix(e2e): allow unattended benchmark writes
Problem:
The real-provider benchmark ran headlessly without an automatic writer posture and without a usable Linux sandbox backend, so valid file edits were denied and the suite exhausted its token budget.

Root cause:
The harness launched reasonix run in its default Ask mode, while the workflow did not install bubblewrap on the runner.

Fix:
Launch fixed-suite tasks with --auto and install plus verify bubblewrap before running the benchmark. Explicit ask/deny rules and the sandbox boundary remain enforced.

Verification:
go test ./cmd/e2ebench -count=5
go vet ./cmd/e2ebench
go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/e2e-bot.yml
bash scripts/release-workflows.test.sh
2026-08-03 17:55:50 +08:00
SivanCola 689351a497 docs(release): complete v1.19.4 notes
Summary:
Add the merged native icon repair and Remote profile resync fixes to the reviewed bilingual release record. Restore the complete categorized change list and remove generated version-title duplication and bot attribution.

Testing:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.19.4
- git diff --check

Privacy:
- Scanned source and rendered notes for credentials and private paths.
2026-08-03 17:49:57 +08:00
SivanCola 8fd5c1f6e2 fix(desktop): clarify usage statistics tab label
Problem:
The usage statistics subtab reused the generic model usage heading and appeared as 使用模型, which described model selection instead of statistics.

Root cause:
The subtab and the model configuration section shared settings.modelUsage even though they represent different concepts.

Fix:
Add a dedicated settings.modelTab.stats locale key for English, Simplified Chinese, and Traditional Chinese, and use it only for the statistics subtab.

Verification:
pnpm test:usage-stats
pnpm typecheck
Browser DOM verification confirmed the 用量统计 tab renders and activates with charts.
2026-08-03 17:49:10 +08:00
github-actions[bot] 23aadfb6e0 docs(release): prepare v1.19.4 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-03 09:47:51 +00:00
SivanCola 26e004f4ba Merge remote-tracking branch 'origin/main-v2' into docs/review-v1.19.4-r2 2026-08-03 17:44:34 +08:00
SivanCola b41fc3cb72 Merge pull request #7299 from SivanCola/fix/e2e-provider-credential-path
fix(e2e): use isolated Reasonix home / 使用隔离的 Reasonix Home
2026-08-03 17:44:09 +08:00
SivanCola 642f937ab4 feat: add Token Rhythm provider with model-aware reasoning 2026-08-03 17:41:01 +08:00
SivanCola 3d35be9099 Merge pull request #7296 from SivanCola/fix/desktop-icon-self-repair
Fix desktop shortcut icons after updates / 修复更新后的桌面快捷方式图标
2026-08-03 17:40:15 +08:00
SivanCola a77ba2e509 Merge pull request #7277 from SivanCola/fix/remote-profile-resync-loop
fix(remote): Stop profile resync loops / 修复远程 Profile 重同步死循环
2026-08-03 17:38:29 +08:00
SivanCola 1b0dbe093e fix(desktop): disclose long decision descriptions
Problem:
Long decision descriptions could overlap adjacent options or grow approval surfaces beyond the viewport, while only Ask exposed a complete-text path.

Root cause:
Decision rows mixed fixed-density styling with surface-specific overflow behavior, and recovery approval removed the shared height limits.

Fix:
Move description overflow detection and disclosure into a shared PromptAction component, apply three-line summaries and full-text controls by decision risk, preserve dangerous runtime consequences, and bound recovery content with internal scrolling. Raise only the raw initial-asset budget by 6 KiB for the accessible disclosure behavior; gzip, CSS, and chunk budgets remain unchanged.

Verification:
pnpm typecheck
pnpm check:css
pnpm exec tsx src/__tests__/ask-card-layout.test.ts
pnpm exec tsx src/__tests__/decision-surface.test.tsx
pnpm test
pnpm build
Browser checks at 320, 768, 1024, and 1440px
2026-08-03 17:30:11 +08:00
SivanColaandHaoyueQin 9c74e799c3 fix(stats): make usage accounting accurate and bounded
Problem:
Usage stats accepted unbounded ranges, missed synchronous turns and hidden provider retries, and attributed several auxiliary model calls incorrectly. Source changes could also leave stale heatmap data visible.

Root cause:
Accounting was tied to UI TurnDone/Usage event shape, child runtimes propagated raw overrides instead of canonical identities, and stats rows had no request multiplicity. The lazy panel also kept locale resources in the eager bundle.

Fix:
Merge main-v2, add completion accounting capabilities, canonical model attribution, request multiplicity, bounded custom ranges, protocol-audit forwarding, and stale-result clearing. Keep usage panel locale/theme resources lazy and cover the current main sanitizer contract.

Verification:
go test -p 4 -timeout=5m ./...
go test -race -timeout=5m ./internal/stats ./internal/event ./internal/control ./internal/agent ./internal/guardian ./internal/serve
(cd desktop && go test ./...)
(cd desktop/frontend && pnpm test:usage-stats && pnpm typecheck && pnpm build)

Co-authored-by: HaoyueQin <[email protected]>
2026-08-03 17:29:18 +08:00
SivanCola bd27491c11 fix(e2e): use isolated Reasonix home
Summary:
Place both the e2e provider config and credential file under an explicit ephemeral REASONIX_HOME. Keep the checkout-level config only for diff mode.

Why:
The first credential-gate fix used the legacy XDG config directory. Current Linux Reasonix uses ~/.reasonix by default and strictly resolves provider secrets from <Reasonix home>/.env, so the fixed suite still reported missing credentials.

Testing:
- bash scripts/release-workflows.test.sh
- actionlint .github/workflows/e2e-bot.yml
- focused Reasonix home and credential path tests
- git diff --check

Privacy:
- Scanned the public diff for credential and private-path patterns.
2026-08-03 17:28:09 +08:00
SivanCola 42147e9ac0 Merge remote-tracking branch 'origin/main-v2' into docs/review-v1.19.4-r2 2026-08-03 17:24:35 +08:00
SivanCola 5cbf8f60f6 Merge pull request #7297 from SivanCola/fix/e2e-provider-credential-gate
fix(e2e): enforce provider credential gate / 强制真实 Provider 凭据门禁
2026-08-03 17:24:15 +08:00
SivanCola 75e84414ac fix(desktop): avoid rewriting healthy icon integrations
Problem:
The startup repair could rewrite healthy Windows shortcuts and macOS aliases on every launch, overriding a custom Windows icon and causing unnecessary filesystem churn.

Root cause:
The repair validated ownership but did not distinguish stale version-scoped icon references or old alias targets from already healthy integrations.

Fix:
Repair Windows only when IconLocation points at the old versioned desktop executable, and leave healthy macOS aliases untouched when they already resolve to the running bundle.

Verification:
- go test ./...
- go test -race . -run TestRepairMacDesktopAliases|TestDesktopPackagesPreserveNativePlatformLaunchers
- go vet ./...
- Windows amd64 and arm64 cross-compilation
- git diff --check
2026-08-03 17:20:42 +08:00
SivanCola 1bdf903ee8 fix(windows): preserve customized Reasonix shortcuts
Problem:
The startup repair rewrote every existing Reasonix shortcut and could discard user-customized targets, arguments, working directories, or descriptions.

Root cause:
Shortcut ownership was inferred from the filename alone, and the repair replaced all shortcut properties.

Fix:
Verify that the existing target belongs to the current Reasonix installation and update only IconLocation. Preserve unrelated shortcuts and every user-customizable property.

Verification:
- go test ./...
- go test -race . -run TestRepairMacDesktopAliases|TestDesktopPackagesPreserveNativePlatformLaunchers
- go vet ./...
- Windows amd64 and arm64 cross-compilation
- git diff --check
2026-08-03 17:15:27 +08:00
SivanCola 41e0c542ca fix(desktop): repair native shortcut icons after updates
Problem:
Desktop shortcuts and Finder aliases could lose their icons after an update.

Root cause:
Windows shortcut icons referenced version-scoped executables removed by retention, macOS bundle replacement could leave Finder aliases pointing at an old bundle, and Linux desktop caches could remain stale after package upgrades.

Fix:
Repair existing Windows and macOS desktop integrations on startup, keep Windows shortcut icons on the stable launcher, and refresh Linux desktop caches from the package post-install script.

Verification:
- go test ./...
- go test -race . -run TestRepairMacDesktopAliases|TestDesktopPackagesPreserveNativePlatformLaunchers
- go vet ./...
- Windows amd64 and arm64 cross-compilation
- Wails macOS arm64 production build
- Linux nFPM post-install package smoke test
2026-08-03 17:10:06 +08:00
SivanCola a14b28d1f7 fix(e2e): enforce provider credential gate
Summary:
Write the repository provider secret to the ephemeral global Reasonix credential file used by the candidate process. Fail fixed-suite e2e runs when any task fails or is skipped while still posting the report.

Why:
The e2e workflow injected the key only as a process variable, which the credential boundary intentionally ignores. The harness then reported 0/5 but returned success, creating a false-green release signal.

Testing:
- bash scripts/release-workflows.test.sh
- actionlint .github/workflows/e2e-bot.yml
- git diff --check

Privacy:
- Scanned the public diff for credentials, local user paths, and private temporary paths.
2026-08-03 17:08:11 +08:00
YHH 8675031b19 fix(config): stop tests reading the developer's real credentials (#7290)
Closes #2419.

RunWithIsolatedUserState redirects HOME, USERPROFILE, XDG_* and AppData, which
covers every location addressed by a path. The OS keyring is not one of those:
it is per-user and has no environment variable in front of it, so legacy
credential migration walked straight past the sandbox into the real store.

On a machine with credentials saved, TestMigrateImportsLegacyStateHomeDotEnv-
Credentials fails by printing the developer's actual API key where its own
fixture was expected. On a clean CI runner the keyring is empty, so it passes —
the suite only breaks for people who use the tool they are working on, which is
why the failing package in #2419 stayed unexplained for two months.

Substituting the lookup once in TestMain makes isolation the default for the
package; the three cases that exercise the lookup already install their own.

Two things verify it, because they fail in different places:

  scripts/credential-leak-check.mjs runs the suite and flags any credential
  printed that appears nowhere in the repo's sources — a value the tests did not
  write can only have come from the machine. It reported one leak before this
  change and none after, and it is the only check that can observe the bug at
  all, since it needs a populated keyring.

  TestKeyringLookupStaysOutOfTheRealStore asserts the substitution itself, so it
  holds on an empty CI keyring where the leak is invisible. Removing the
  substitution turns it red.

Neither prints a credential; the tool reports a SHA-256 prefix and the guard
reports a byte count.
2026-08-03 17:03:43 +08:00
SivanCola 56c7f4ad74 docs(release): review updated v1.19.4 notes
Summary:
Refine the regenerated bilingual release notes after the Creation UI merge. Keep the dashboard comparison tied to its live-query evidence and remove automated accounts from contributor credits.

Testing:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.19.4
- git diff --check

Privacy:
- Scanned rendered and source release notes for credentials, local paths, and private identifiers.
2026-08-03 17:00:43 +08:00
github-actions[bot] 5482b42666 docs(release): prepare v1.19.4 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-03 08:59:10 +00:00
SivanCola 46feeb71af fix(desktop): synchronize workbench runtime epochs
Problem:
Remote Workbench events carried the Host runtime epoch while the shared frontend surface could still retain its Local epoch, causing valid Remote events to be discarded.

Root cause:
Remote Meta and tab projections did not publish the Remote runtime identity, and initial pending-prompt replay could overtake the runtime:rebuilt transition after subscription.

Fix:
Project the authoritative runtime epoch through Meta and ListTabs, publish the initial Remote epoch inside the committed subscription fence, and restore the Local epoch on every switch or disconnect path. Add frontend and Desktop regressions for Local-to-Remote event admission and Local restoration.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop && go test -race . -run Workbench -count=1
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime -count=1
- cd desktop/frontend && pnpm typecheck && pnpm test:typecheck
- cd desktop/frontend && pnpm test:remote
- cd desktop/frontend && pnpm exec tsx src/__tests__/pending-prompt-stale-status.test.tsx
2026-08-03 16:52:14 +08:00
SivanCola c9da184528 Merge pull request #7276 from Li-Charles-One/feat/creation-composer-mode-pills
feat(creation): 精简输入模式 pill 并将标题栏文件夹前置
2026-08-03 16:39:58 +08:00
SivanCola 4eeeb0a8eb Merge pull request #7289 from esengine/release-notes/v1.19.4
docs(release): prepare v1.19.4 bilingual notes / 准备 v1.19.4 中英更新日志
2026-08-03 16:30:33 +08:00
SivanCola 4ea9ceee10 docs(release): refine v1.19.4 notes
Summary:
Polish the reviewed bilingual release notes around the user-facing 1.19.4 outcomes.

Corrections:
Remove the duplicated version title, clarify the production dashboard comparison, describe Scoop directory junctions precisely, and keep automated accounts out of contributor credits.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.19.4
- git diff --check
2026-08-03 16:27:45 +08:00
github-actions[bot] 0d5b471f8c docs(release): prepare v1.19.4 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-03 08:25:09 +00:00
SivanCola df05ddd1bb Merge pull request #7287 from SivanCola/test/scoop-desktop-launch-gate
test(windows): gate Scoop desktop launch path / 增加 Scoop 桌面启动门禁
2026-08-03 16:19:17 +08:00
SivanCola ec8e8efba3 fix(creation): simplify hover timer state
Problem:
The PR merge exceeded the desktop initial raw bundle budget by 0.2 KiB.

Root cause:
Each Creation hover menu kept separate enter and leave timer refs even though those timers are mutually exclusive.

Fix:
Reuse one timer ref per menu while preserving the existing hover delays and close guards.

Verification:
- frontend test suite
- frontend typecheck and test:typecheck
- CSS checks
- frontend production build

Co-authored-by: SivanCola <[email protected]>
2026-08-03 16:12:20 +08:00
SivanCola a2073c6553 fix(remote): fence late control-plane responses
Problem:
Late no-op profile responses and epoch-less Desktop event projections could cross a runtime replacement boundary and restore or mutate stale client state.

Root cause:
The Desktop client allowed multiple mutations in flight and copied unchanged profile results back into authority, while the Remote adapter discarded SessionEvent.RuntimeEpoch.

Fix:
Serialize client mutations and session selection, keep unchanged profile results side-effect free, preserve event runtime epochs, and add deterministic regression coverage.

Verification:
- go test ./...
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime -count=1
- (cd desktop && go test ./...)
- (cd desktop && go test -race . -run RemoteWorkbench -count=1)
- (cd desktop/frontend && pnpm typecheck)
- Windows/ARM64 integration-tag test binary cross-compile
2026-08-03 16:05:45 +08:00
HaoyueQin 9116a3b304 feat(stats): add per-day usage statistics panel with cache hit-rate
Adds a "Usage Statistics" subtab to the desktop Settings → Models page:
time-range presets (7/14/30/90 days or custom), token/session/request
cards, active-days + average cache hit-rate + top-model cards, a fixed
40-week activity heatmap with per-day cache hit rate, a stacked per-day
token trend overlaid with a smooth cache hit-rate curve, and a per-model
donut + list. All charts are hand-drawn SVG; component CSS loads on demand
to stay inside the initial bundle budget.

All frontends (desktop, cli, serve, bot, remote workbench) record usage
through one passthrough stats.Recorder mounted at the boot layer after
event.Sync. event.Usage carries a new ModelRef field so tokens attribute to
the exact model; event.TurnDone rows count completed turns. Records are
appended as daily JSONL under the user state root (<state root>/stats/), so
usage survives app upgrades and accumulates from the day the feature ships.
The desktop App.UsageStats binding serves range aggregates, and the panel
can split totals by entry point.

Why a dedicated recorder: usage accounting is observational — the stats
sink must never alter the event stream, and a bad disk state must never
interrupt a turn, so writes are best-effort (matching the telemetry append
idiom).
2026-08-03 16:05:13 +08:00
SivanCola fe319846a1 test(windows): gate Scoop desktop launch path
Problem:
The Windows release checks only exercised launcher path resolution in a unit test, so packaging regressions across Scoop installation, its current junction, and Start Menu shortcuts could still ship.

Root cause:
CI did not install a Reasonix fixture through the real Scoop CLI or launch the generated shortcut.

Fix:
Add an isolated Windows integration test that builds a versioned fixture, installs it with Scoop v0.5.3, verifies the current junction and shortcut contract, and launches the desktop marker through that shortcut. Run it on every Windows CI leg.

Verification:
- go test ./internal/desktoplauncher/... ./internal/installlayout/...
- go vet ./internal/desktoplauncher/... ./cmd/reasonix-launcher
- GOOS=windows GOARCH=amd64 go build ./cmd/reasonix-launcher
- actionlint .github/workflows/ci.yml
- git diff --check
2026-08-03 16:04:05 +08:00
SivanCola 9fc382af24 fix(creation): avoid closing unopened hover menus
Problem:
Brief pointer passes over the Creation task/profile pills could enter a closing-only state even though their menus never opened.

Root cause:
The hover-leave handlers cancelled the pending open timer but always scheduled a close timer, which later rendered the popover's closing phase and open trigger styling.

Fix:
After cancelling pending hover timers, schedule a close only when the corresponding menu is actually open or already closing. Add deterministic timer-driven coverage for both pills and preserve sustained-hover opening.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/composer-run-strip.test.tsx
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend test
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-03 16:01:51 +08:00
YHH a91c6c2eef fix(crash-dashboard): put preferences back on the 30-day window (#7284)
With the rollup from #7279 populated, the 30-day view is the fast one: reading
it off metric_user_rollup measures 68ms against production, versus 5,667ms for
the live 7-day query it was defaulted to in #7271. Leaving the default at 7 days
now means defaulting everyone to the slower path and the less representative
window.

That also retires windowExplicit. It existed to stop a module default from
following the reader into other modules; with no module default left it is
always equivalent to windowDays === 7, so it is dead weight.

A precomputed window can go stale silently if the rollup cron stops, so the
panel heading now carries the oldest computed_at across the rollup — the cursor
refreshes a slice at a time, so that is how far behind the least recently
recomputed signal is.
2026-08-03 15:41:32 +08:00
SivanCola fcb12ed886 Merge pull request #7259 from SivanCola/fix/silent-missing-reasoning-recovery
fix(agent): silently recover missing tool-call reasoning / 静默恢复工具调用推理缺失
2026-08-03 15:38:29 +08:00
SivanCola b528d5f416 Merge pull request #7280 from esengine/release-notes/v1.19.4
docs(release): 准备 v1.19.4 中英更新日志
2026-08-03 15:31:32 +08:00
SivanCola 88dbccb552 docs(release): clarify Windows launcher validation
Summary:
Describe the Scoop junction failure as install-root validation so the 1.19.4 notes match the launcher error boundary in #7275.

Verification:
Validated the release catalog, ran the release-note tests, rendered the reviewed bilingual notes, and scanned the public text for private data.
2026-08-03 15:28:42 +08:00
YHH 4f2fb94998 feat(crash-worker): precompute the 30-day preferences deduplication (#7279)
Closes #7272.

The preferences module's 30-day COUNT(DISTINCT install_id) spans ~28M rows of
metric_users and D1 abandons it mid-query, so #7271 pinned the module to the
7-day window it can still finish. This computes the 30-day answer ahead of time
instead.

It has to be computed, not derived: a cross-day COUNT(DISTINCT) cannot be summed
from per-day totals, because an install active on twelve days would count twelve
times. Benchmarks ruled out the alternatives — storing one JSON row per install
per day and expanding it with json_each took 103s at production shape, worse
than what we have, because parsing JSON per row costs more than the I/O it
saves.

What does work is the query split by signal: measured against production, one
signal over the 30-day window is ~1s. An hourly cron walks the signal list eight
at a time behind a cursor, so a run stays well inside its CPU budget and a full
pass takes about seven hours. Each signal's rows are replaced in a single batch,
so a reader never observes one half-written, and a signal whose query is
abandoned still advances the cursor rather than blocking every signal behind it.

The dashboard reads the rollup for the 30-day desktop window and keeps querying
live everywhere else. Until the first pass completes the panel says the
precompute has not reached every signal yet, rather than showing an empty one.

Both tables are created at runtime, matching how ingest_sentinel_state and the
cli_* tables bootstrap, so no manual migration is needed.
2026-08-03 15:27:06 +08:00
github-actions[bot] 82341f8bf5 docs(release): prepare v1.19.4 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-03 07:26:18 +00:00
SivanCola 88feabab21 Merge pull request #7275 from SivanCola/fix/windows-launcher-junction
fix(desktop): resolve Windows launcher junctions / 修复 Windows 启动器目录联接解析
2026-08-03 15:24:04 +08:00
SivanCola c7fc96cda2 merge: integrate main-v2 usage accounting
Problem: The latest main-v2 added failed-stream usage accounting in the same run-loop site wrapped by missing-reasoning recovery, leaving the PR conflicting.

Root cause: The recovery branch returned only an error when a replay was canceled, while the new base expects every failed stream to retain usage for truthful accounting.

Fix: Merge current main-v2 without rewriting branch history, preserve the DeepSeek recovery wrapper and base usage emitter, carry merged first-attempt and retry usage through cancellation, and add a deterministic cancellation regression.

Verification: focused recovery and cancellation tests passed 20 times; go test ./... passed; go test -race ./internal/agent -count=1 passed; two post-merge real DeepSeek transient and persistent recovery rounds passed.
2026-08-03 15:13:02 +08:00
SivanCola 87633b8a17 merge(main-v2): sync remote profile fix with latest base
Context:
Bring the published fix branch onto the current main-v2 without rewriting its public history.

Integration:
Preserve the Remote Workbench profile idempotence, runtime replacement, frontend deduplication, and Windows physical acceptance coverage while incorporating the latest agent and desktop fixes.

Verification:
The merged head will be regenerated and revalidated with protocol, desktop, frontend, race, and physical Windows Remote Workbench tests before opening the pull request.

# Conflicts:
#	desktop/frontend/src/generated/remoteProtocol.generated.ts
#	internal/remote/protocol/schema.generated.json
#	internal/remote/protocol/schema_hash.generated.go
2026-08-03 15:09:32 +08:00
SivanCola 8266d6c8d1 test(agent): accept safe no-retry live fallback
Problem: The credential-gated DeepSeek probe required every stripped reasoning response to produce an identical retry, so a valid visible-text fallback could fail the test even though the runtime stayed safe.

Root cause: The live assertion did not distinguish a retryable tool-only response from a response whose visible text intentionally suppresses retry to avoid duplicate output.

Fix: Validate single tool execution, zero user warnings, and the fallback outcome before treating no-retry responses as alternate provider shapes and continuing the bounded probe.

Verification: focused recovery regressions passed 20 times; the internal agent suite passed; the live-tag race build passed; three real DeepSeek transient and persistent recovery rounds passed.
2026-08-03 15:06:03 +08:00
SivanCola 3ba9825b60 test(desktop): compare junction target identities
Problem:
Windows CI may expose a temporary directory through an 8.3 short path while GetFinalPathNameByHandle returns the equivalent long path.

Root cause:
The regression test compared path strings, so two names for the same Windows directory produced a false failure.

Fix:
Compare the resolved and expected directories with os.SameFile so the assertion verifies filesystem identity across long and short path aliases.

Verification:
Ran the local desktoplauncher tests and cross-compiled the Windows test binary for amd64 and arm64.
2026-08-03 15:05:16 +08:00
SivanCola c50f51b29b Merge pull request #7186 from paradoxSCH/fix/agent-runaway-usage-accounting
fix(agent): guard runaway reasoning usage
2026-08-03 15:02:30 +08:00
SivanCola 3f972b9b8d test(remote): accept wrapped physical user prompts
Problem:
The Windows physical Remote Workbench fixture required the Broker user message to equal the bare prompt, but current prompt assembly prepends the response-language contract.

Fix:
Keep the semantic protection by requiring the final message to have the user role and contain the original acceptance prompt, while allowing stable runtime-owned wrappers.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
2026-08-03 15:01:01 +08:00
SivanCola 66991e1983 fix(desktop): resolve Windows launcher junctions
Problem:
The Desktop launcher fails when Scoop invokes it through the stable current directory junction because the install-root guard receives the unresolved alias.

Root cause:
filepath.EvalSymlinks does not reliably canonicalize Windows directory junctions, and the previous fallback silently retained the junction path.

Fix:
Resolve the running launcher through a Windows file handle with GetFinalPathNameByHandle, normalize DOS and UNC extended paths, preserve the existing non-Windows fallback, and cover the junction path in Windows CI.

Verification:
Ran root and Desktop Go test and vet suites, focused race tests, repeated the unrelated timing-sensitive agent test, cross-compiled Windows launcher tests and binaries for amd64 and arm64, and validated the CI workflow.
2026-08-03 14:59:39 +08:00
SivanCola 6fde1e899b test(remote): exercise idempotent profile over Windows SSH
Problem:
The physical Windows Remote Workbench acceptance covered SSH, tools, history, and reconnects but did not exercise the profile feedback-loop regression from issue #7268.

Fix:
Apply the current collaboration, approval, and empty Goal profile twice over the live Windows OpenSSH connection. Require both calls to return unchanged, preserve the runtime epoch, and keep the subscribed snapshot authoritative before continuing the tool loop.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
- cd desktop && go test . -run profile/runtime focused suite
2026-08-03 14:59:16 +08:00
Li-Charles-One f2aaa68ccd feat(creation): lead topicbar with slim external opener 2026-08-03 14:56:59 +08:00
SivanCola 42e8dfdc48 Merge pull request #7195 from paradoxSCH/fix/provider-model-picker-hooks
fix(desktop): stabilize provider model picker hooks
2026-08-03 14:50:57 +08:00
SivanCola 5f236a5def test(remote): restore Windows physical acceptance build
Problem:
The opt-in Windows Remote Workbench acceptance test no longer compiled after the Windows SSH factory constructor began returning the shared transport.Factory interface.

Root cause:
The fixture still called Windows-specific peer identity and process lifecycle helpers directly on the interface result.

Fix:
Assert the expected concrete Windows factory immediately after construction so the physical fixture can inspect authenticated peer identity and verify SSH process cleanup.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
- cd desktop && go test . -run profile/runtime focused suite
2026-08-03 14:49:53 +08:00
Li-Charles-One 7d114dc2fe feat(creation): slim task/profile pills and open menus on hover
Hide icons/chevrons on 常规/均衡 triggers, tighten text-only pills, and open their menus on hover in Creation only.
2026-08-03 14:40:56 +08:00
SivanCola a1eca07990 fix(remote): stop profile resync feedback loops
Problem:
Remote Workbench could enter an unbounded session/profile/set and session/resync_required loop, making the Windows UI unresponsive.

Root cause:
No-op profile writes still invalidated subscriptions, snapshot refreshes emitted synthetic runtime rebuilds, and frontend profile deduplication followed prompt churn instead of actual runtime identity.

Fix:
Return an explicit unchanged disposition for idempotent profile writes, emit runtime replacement events only for real controller identity changes, serialize profile and session rotations, and deduplicate frontend profile application by runtime identity and profile content. Apply the same no-op discipline to Goal mutations and session rotations.

Verification:
- go test ./internal/remote/protocol ./internal/remote/protocolgen ./internal/remote/workbench/client ./internal/remote/workbench/runtime
- go test ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm typecheck
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime
- cd desktop && go test -race . -run profile/runtime focused suite
2026-08-03 14:40:48 +08:00
SivanCola 794c6be461 Merge pull request #7270 from SivanCola/fix/unify-windows-dock-tabs
fix(desktop): align Windows workspace dock tabs / 统一 Windows 工作区标签栏
2026-08-03 14:39:39 +08:00
YHH 11e4c54104 fix(crash-dashboard): default preferences to the window D1 can finish (#7271)
metric_users takes ~1M rows a day, so its 30-day COUNT(DISTINCT install_id)
now spans ~28M rows and D1 gives up on it — measured against production, the
query comes back as a CPU-limit reset. Dropping the window-scan index in
#7258 fixed the tables around it but could not fix this one: the 7-day window
completes in 5.7s over 6.9M rows, the 30-day one does not complete at all.

So the module now defaults to 7 days. An explicitly chosen 30d still runs,
and when it fails it says so: metricUserRows returns null for did-not-complete
rather than folding it into the empty array, which the dashboard was rendering
as its no-metrics-yet state — a timeout reading as nobody using these settings.

A module default must not leak, so windowExplicit tracks whether the reader
picked the window; only a chosen 7d is carried into the other modules' links.

This is a stopgap. The real fix is pre-aggregation, which needs its own
design: a cross-day COUNT(DISTINCT) cannot be summed from daily rollups.
2026-08-03 14:26:19 +08:00
SivanCola a3e2e6f62f fix(agent): cancel guarded reasoning streams
Problem:
The client reasoning guard could return without canceling the provider stream, while automatic DeepSeek output limits also capped thinking-disabled requests and Responses vendor detection accepted substring matches.

Root cause:
Agent stream consumption did not own a child cancellation context, and provider defaults were selected before effective reasoning mode and strict hostname classification were applied.

Fix:
Cancel every provider stream when Agent.stream exits, scope the automatic 32K DeepSeek budget to reasoning-enabled requests while preserving explicit limits, and detect Responses vendors from parsed hostnames. Add focused lifecycle, serialization, and spoofed-host regressions.

Verification:
- go test ./internal/agent ./internal/provider/openai ./internal/provider/responses ./internal/provider/anthropic -count=1
- go test -race ./internal/agent ./internal/provider/openai ./internal/provider/responses -count=1
- go vet ./...
- REASONIX_RELEASE_CACHE_GUARD=1 go test ./internal/agent -run TestReleaseCacheHitGuard -count=1 -v

Co-authored-by: SivanCola <[email protected]>
2026-08-03 14:20:02 +08:00
SivanColaandArdian Nurcahya 4a05f32d85 fix(desktop): normalize provider model metadata
Problem:
Malformed or legacy provider catalog payloads can contain null display fields, which makes model search, provider grouping, and settings rendering unsafe.

Root cause:
The frontend trusted non-null TypeScript bridge contracts at runtime and passed provider/model/name/baseUrl values directly to string operations.

Fix:
Normalize model and provider display metadata at their component ingestion boundaries, preserving the hook-order fix from #7195 while adapting the defensive null handling contributed in #7239.

Verification:
- pnpm test
- pnpm run typecheck
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm exec tsx src/__tests__/model-switcher-refresh.test.tsx
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- git diff --check

Co-authored-by: Ardian Nurcahya <[email protected]>
2026-08-03 14:15:41 +08:00
2a2d0e674a fix(agent): require stable reasoning before rearming
Problem: one healthy tool-call turn immediately re-armed silent recovery, so an intermittently broken endpoint could flap into repeated extra requests.

Root cause: the v2 incident state had only missing and resolved watermarks, with no compatible way to retain consecutive healthy progress.

Fix: keep the v2 document version, add optional streak and healthy-watermark fields, require three consecutive healthy turns, and protect in-memory, cross-process, stale-event, duplicate-event, and I/O-failure boundaries.

Verification: focused regressions (20x), go test ./..., go test -race ./internal/agent, go vet ./..., golangci-lint, and the cache guard.

Co-authored-by: orz0219 <[email protected]>

Co-authored-by: Lxiny-zy <[email protected]>
2026-08-03 14:12:36 +08:00
SivanCola 38a226ab6c fix(desktop): align Windows workspace dock tabs
Problem: Classic and Workbench rendered different right-dock tab geometry on Windows, while the independent terminal drawer also appeared as a fifth workspace tab.

Root cause: Workbench compressed its tab spacing separately, Classic reserved native window-control space on an already separated row, and the terminal drawer action was duplicated in the dock tab list.

Fix: Keep four shared workspace tabs, retain terminal access in the topic bar, separate Workbench window controls from the tab row, and share dividers and narrow-width spacing across Classic and Workbench.

Verification: app-chrome-tabs 120/120 passed; workspace-layout 40/40 passed; CSS syntax and z-index checks passed; production frontend build and bundle budgets passed; Windows browser preview showed matching complete tab geometry with no console warnings or errors.
2026-08-03 14:12:26 +08:00
paradoxSCHandSivanCola 5dfbbe00ef fix(desktop): stabilize provider model picker hooks
Problem:
The custom provider model picker crashed with React error #310 after an asynchronous model fetch changed the candidate list from empty to populated.

Root cause:
ProviderEditorModelPicker returned before useDeferredValue for an empty candidate list, so consecutive renders called a different number of React hooks.

Fix:
Keep the hook order stable by calling useDeferredValue before the empty-list return, and add a focused regression test for the empty-to-loaded transition.

Verification:
- pnpm exec tsx src/__tests__/provider-editor-model-picker.test.tsx
- pnpm exec tsx src/__tests__/provider-model-refresh.test.ts
- pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- pnpm run typecheck
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-03 13:49:12 +08:00
SivanCola b297e9616c test(agent): lock non-DeepSeek recovery boundary
Contract: Providers without the DeepSeek tool-call reasoning policy must keep the ordinary tool loop when reasoning content is absent.

Coverage: Assert two provider calls, one tool dispatch, and zero protocol recovery audits.

Verification: go test ./...; focused agent and race tests.
2026-08-03 13:23:15 +08:00
YHH 69d0944e63 fix(crash-worker): stop recreating the cli_* window-scan indexes (#7266)
#7258 dropped the three desktop indexes that cost the dashboard its window
prune, but missed their cli_ mirrors — and those are worse than a stale
migration: ensureCLITelemetrySchema issues the CREATE INDEX statements on
every cold start, so dropping them in D1 only lasts until the next CLI
request. Production still had all six.

The cli_ tables carry the same primary keys as their desktop counterparts,
so they lose the same way. Remove the CREATE INDEX statements from both the
runtime schema and migrate-client-surface.sql, and extend the drop migration
to cover all six.

The migration is now ordered against a deploy: it must run after a worker
that no longer recreates them.
2026-08-03 12:52:41 +08:00
SivanCola 56b5fca57c fix(agent): classify replaced reasoning retries
Problem:
A byte-identical missing-reasoning retry can return a normal final answer instead of another tool call, but the recovery audit classified every non-missing retry as recovered reasoning. The live DeepSeek behavior also lacked a reusable credential-gated regression probe.

Root cause:
The recovery outcome only distinguished another missing tool-call response from all other retry shapes, so a replaced response inflated the recovered counter even though the discarded tool call correctly stayed unexecuted.

Fix:
Record retry-replaced responses separately, preserve whole-response adoption before tool execution, cover the shape change and usage merge deterministically, and add bounded transient/persistent live probes against the official API without logging credentials or content.

Verification:
- go test ./...
- go test -race ./internal/agent
- go test -race ./internal/event ./internal/cli ./internal/telemetry ./internal/notify
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- env -u DEEPSEEK_API_KEY go test -tags live ./internal/agent -run TestLiveDeepSeekFlashMissingReasoningRecovery -count=1
2026-08-03 12:41:02 +08:00
SivanColaandSivanCola 12c4dbb624 fix(agent): scope output budgets by provider protocol
Problem:
The reasoning byte guard was converted into one 32K provider token limit and sent to every backend, coupling a local safety fallback to unrelated provider wire contracts.

Root cause:
The agent owned both controls, so OpenAI-compatible, Responses, Anthropic, DeepSeek, and unknown gateways could not choose model-appropriate fields or defaults.

Fix:
Keep the client byte guard independent, add provider and per-model max_output_tokens configuration, default only official DeepSeek endpoints to 32K, and serialize each supported protocol's native field while leaving unknown gateways unchanged unless configured. Merge current main-v2 and retain both status-bar test suites.

Verification:
go test focused provider/agent/config/boot suites; go test -race focused suites; desktop go test; frontend test/build/typecheck; cache guard; go vet; diff/gofmt checks.

Co-authored-by: SivanCola <[email protected]>
2026-08-03 12:40:40 +08:00
SivanCola e4bbe48890 fix(agent): silently recover missing tool-call reasoning
Problem:
DeepSeek V4 Flash can intermittently return tool calls without replayable reasoning_content, exposing a provider-protocol warning and degrading later tool rounds.

Root cause:
The agent treated the malformed turn as a user-visible diagnostic and immediately committed it, while the persisted cooldown only rate-limited the notice instead of attempting recovery.

Fix:
Retry one byte-identical request before tool execution, keep speculative stream events invisible, reuse the configuration-scoped cooldown as a recovery circuit breaker, preserve the empty-key fallback, and record content-free recovery metrics.

Verification:
- go test ./...
- go test -race ./internal/agent
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
2026-08-03 12:04:28 +08:00
YHH 88b9fa0869 perf: drop the dashboard's window-scan indexes, bake avatars as webp (#7258)
* perf(crash-worker): drop the window-scan indexes the dashboard loses to

Every dashboard aggregate is `WHERE date >= ... GROUP BY <other columns>`,
and each table's primary key already starts with `date`. Offering SQLite a
second index on the GROUP BY columns makes it prefer an ordered full-index
scan, which skips the window prune and reads every retained row back through
the table. The same query shape with a `date <` upper bound already picks the
primary key, which is why the previous-window delta was never slow.

Measured on seeded copies, 65 days retained against the 30-day window:

  metric_users  5.3M rows   31.6s -> 3.3s
  metrics       180k rows    807ms -> 88ms
  pings         568k rows    672ms -> 459ms

No query filters these tables by the dropped columns, so this also takes
their maintenance cost off the ingest path.

* perf(site): bake contributor avatars as 128px webp

The prebuild wrote whatever GitHub handed back straight to `<login>.png`.
Of the 98 baked files, 58 were actually JPEG behind a .png name, 19 came
back at 420x420 despite the ?s= hint, and the heaviest 120x120 avatar was
76 KB. The homepage renders all 98 at 60 CSS px.

Re-encode through sharp at twice the largest rendered size instead of
trusting the response. Avatars 823 KB -> 252 KB; the homepage drops from
842 KB to 371 KB, still 103 requests but a little over half the bytes.

sharp was already resolved in the lockfile as an astro dependency; it is
promoted to a direct devDependency so the prebuild does not rely on hoisting.
2026-08-03 11:57:50 +08:00
SivanCola 22e18cafc0 Merge pull request #7242 from SivanCola/codex/fix-v1193-windows-authenticode
fix(release): verify versioned Windows portable archives
2026-08-03 06:00:46 +08:00
SivanCola b75f2f0dbb fix(release): verify versioned Windows portable archives
Summary:
- validate both legacy flat and versioned-v1 portable layouts
- use the protected control-plane verifier during immutable tag recovery
- keep exact Authenticode and payload hash checks for all six executables

Verification:
- bash scripts/release-workflows.test.sh
- go -C desktop test . -run TestWindowsReleaseSignsPayloadBeforeRepackaging -count=1
- actionlint
- ./scripts/cache-guard.sh
- git diff --check
2026-08-03 05:45:38 +08:00
SivanCola 1eb59c3cd5 refactor(desktop): surface compaction threshold directly
Product: Remove the redundant advanced context disclosure around the sole compaction setting.

Change: Render the threshold controls directly in Agent runtime, preserve the low-touch guidance in localized helper text, and cover the visible-without-expansion contract.

Verification: pnpm exec tsx src/__tests__/settings-refresh-snapshot.test.tsx; pnpm build; in-app browser visual and console checks.
2026-08-03 05:36:40 +08:00
SivanCola 15d335b86b Merge pull request #7237 from esengine/release-notes/v1.19.3
docs(release): prepare v1.19.3 notes
2026-08-03 02:05:59 +08:00
github-actions[bot] 9e9f24a77c docs(release): prepare v1.19.3 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-02 18:03:23 +00:00
SivanCola 427fba0f27 Merge pull request #7198 from esengine/fix/background-job-visibility
fix(desktop): make blocked Delivery runtimes recoverable
2026-08-03 01:52:13 +08:00
SivanCola ae309c967a fix(desktop): preserve job ownership boundaries
Problem:
Network probes bypassed permission prompts, and local background jobs could not be listed or stopped while Remote Workbench was active.

Root cause:
The shared read-only classifier conflated workspace mutation with permission safety, while job APIs selected the active remote target before honoring an explicit tab owner.

Fix:
Separate workspace-nonmutating commands from permission-read-only commands, keep Test-NetConnection outside read-only subagents, and route job listing and cancellation by explicit local or remote tab ownership.

Verification:
Ran focused permission, evidence, agent, and Desktop tests, both Go module suites, race tests, vet, and the cache guard.
2026-08-03 01:27:16 +08:00
SivanCola df04b11b06 Merge pull request #7227 from SivanCola/feature/stable-only-release-flow
feat(release): keep one official Stable publication path / 保留单一正式版发布路径
2026-08-03 01:14:37 +08:00
SivanCola ab24371d98 fix(release): bind stable tags to reviewed candidate
Problem:
The stable helper could consume immutable version tags if main-v2 advanced while exact-SHA CI or the protected relay was starting. Manual CLI and Desktop recovery also still accepted RC tags despite the official-only release contract.

Root cause:
The helper sampled main-v2 once, pushed only tag refs, and the orchestrator required the tags to equal the later branch tip. The candidate was not proven to be the commit that changed the target reviewed Notes record.

Fix:
Bind normal candidates to their reviewed Notes change, revalidate exact push CI from the protected control plane, include main-v2 as a guarded ref in the atomic tag transaction, and accept the immutable candidate after later protected branch advances. Restrict manual CLI and Desktop recovery to official versions while retaining historical reusable-workflow parsing.

Verification:
- bash scripts/release-stable.test.sh
- bash scripts/release-workflows.test.sh
- node --test scripts/release-notes.test.mjs
- actionlint on all active release workflows
- site contract tests, SignPath contract tests, Desktop control-plane test, cache guard, and git diff --check
2026-08-03 01:05:09 +08:00
SivanCola 7bb2054edc Merge latest main-v2 into context threshold settings
Integrate the v1.20 update and recovery changes while preserving the context compaction threshold UI, validation, compatibility defaults, and tests.
2026-08-03 00:58:38 +08:00
SivanCola d9b90207be feat(desktop): allow custom compaction thresholds
Motivation:
- Let advanced users choose a threshold between the preset values without editing TOML.

Implementation:
- Add a 65% to 85% percentage draft with 0.1% steps and explicit save semantics.
- Keep preset buttons, validation, cache-risk guidance, and localized accessibility labels.
- Revert the draft to the persisted value when a guarded settings update fails.

Verification:
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- ./scripts/cache-guard.sh
- git diff --check
2026-08-03 00:58:34 +08:00
SivanCola eee6334921 Merge pull request #7199 from SivanCola/feature/v1.20-remove-guard-update-v2
Remove Guard and make Desktop updates atomic / 移除 Guard 并实现桌面端原子更新
2026-08-03 00:55:38 +08:00
SivanCola 72a2804b6a feat(desktop): expose context compaction threshold
Motivation:
- Let advanced users trade context headroom against prompt-cache reuse without editing TOML.

Implementation:
- Add guarded 70%, 80%, and 85% presets with effective token and scope guidance.
- Persist the validated global ratio through the existing failure-atomic settings rebuild path.
- Preserve older config and Wails compatibility with additive optional fields and frontend defaults.

Verification:
- go test ./...
- (cd desktop && go test ./...)
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx
- ./scripts/cache-guard.sh
2026-08-03 00:51:43 +08:00
SivanCola 6bd942a090 test(release): retire the Desktop prerelease relay contract
The Desktop CI still opened release-desktop-trigger.yml after the public prerelease relay was intentionally removed, causing the first PR head to fail.

Keep the protected Stable relay assertions and replace the deleted relay expectation with explicit absence checks for all three retired public prerelease workflows.

Verification:
go -C desktop test . -run TestProductionSigningRunsOnlyFromProtectedControlPlane -count=1
bash scripts/release-workflows.test.sh
git diff --check
2026-08-02 23:50:39 +08:00
SivanCola 97b42efe4a Merge latest main-v2 site navigation
Problem: main-v2 advanced after the lint repair was prepared, so the PR branch would otherwise become stale again before push.

Root cause: PR #7226 merged the shared site GitHub navigation contract during final verification.

Fix: Merge the latest base without changing the Desktop update implementation.

Verification: pnpm test in site passes all 48 contract tests; git diff --cached --check passes.
2026-08-02 23:49:25 +08:00
SivanCola 94f627c535 Remove obsolete repair state locks
Problem: The PR lint job failed because lockRepairStateFile was unused after the StartupTracker write path was removed.

Root cause: The Unix, Windows, and fallback platform helpers were left behind when startup recovery state became read-only diagnostics.

Fix: Delete the three dead platform implementations so no Guard-era repair-state lock surface remains.

Verification: golangci-lint v2.12.2 reports 0 issues; go test and go vet pass for internal/repair; the package cross-compiles for Windows amd64.
2026-08-02 23:48:53 +08:00
SivanCola 087aaa3a9b chore(release): retire the npm alias migration action
The one-time migration completed successfully in Actions run 30754751693. All seven packages now resolve latest, canary, and next to 1.19.2 with candidate provenance c46e3af1c2.

Remove the temporary manual workflow and its actionlint entry so the normal developer surface contains only ongoing release operations.

Verification:
Re-ran release workflow contracts, site tests, SignPath contract validation, actionlint, cache guard, and diff checks after removal.
2026-08-02 23:47:34 +08:00
SivanCola 93b47a036a Merge pull request #7226 from SivanCola/feature/site-github-header
Add GitHub header link and responsive navigation / 添加 GitHub 入口并优化响应式导航
2026-08-02 23:47:28 +08:00
SivanCola 08e8e79bcf Merge latest main-v2 release fixes
Problem: main-v2 advanced again while the updater conflict resolution was under verification, leaving the branch behind the current base.

Root cause: PR #7222 merged an npm alias propagation fix after the first integration merge was prepared.

Fix: Merge the new base commit without altering the resolved Desktop updater semantics.

Verification: node --test scripts/finalize-npm-official-release.test.mjs; bash scripts/release-workflows.test.sh; git diff --cached --check.
2026-08-02 23:43:29 +08:00
SivanCola 8847d5b9b8 feat(release): keep one official Stable publication path
Problem:
The dual-channel release controls and the proposed GitHub App activation add operator choices, owner-only setup, and failure modes that are unnecessary now that users consume only official releases.

Design:
Retain the proven three-tag Stable orchestrator, retire public Preview relays, and add one exact-SHA helper that validates reviewed notes and push CI before atomically creating the CLI, npm, and Desktop tags. Keep historical Preview inputs readable only for compatibility.

Experience:
Prepare release accepts one version. After the notes PR merges, a maintainer runs one command and approves the release environment once. The website and public documentation expose only the official line and reasonix upgrade.

Safety:
Preserve immutable tags, protected control-plane checks, SignPath validation, idempotent recovery, npm provenance checks, and bounded alias convergence. No persisted runtime format changes are introduced.

Verification:
Release workflow contracts, atomic tag failure cases, SignPath contract tests, website public-surface tests, actionlint, cache guard, and diff checks pass locally.
2026-08-02 23:43:21 +08:00
SivanCola 99cf86bdf1 Merge main-v2 and preserve atomic official updates
Problem: PR #7199 conflicted with the official single-channel updater changes on main-v2, which prevented GitHub from producing a merge ref and running the full CI matrix.

Root cause: Both branches changed the Desktop updater settings, Wails contracts, configuration compatibility fields, and updater tests from the same older baseline.

Fix: Merge main-v2 while retaining the single ApplyUpdateRequest flow, force every new UI operation onto the official stable-compatible channel, remove the retired channel switch helper and Safe Mode payload, and preserve request/version fences for stale async completions.

Verification: GOWORK=off go test ./... and go vet ./... in both Go modules; focused go test -race for config, install layout, window state, settings, and updater paths; pnpm test:all and pnpm build; Windows amd64 Desktop and update-helper cross-builds; cache guard, cache-impact, and release workflow contract tests.
2026-08-02 23:43:07 +08:00
SivanCola da051f9448 feat(site): add GitHub header navigation
Problem: The shared marketing header did not expose the source repository, duplicated the install destination with a Get started link, and allowed navigation text to overflow into right-side controls at intermediate widths.

Root cause: The responsive rules kept a non-shrinking navigation row visible until 900px even when its flex container was already narrower than its contents.

Fix: Add an accessible GitHub link, collapse the primary navigation before it can overlap, preserve the compact mobile control budget, remove the duplicate Get started item, and cover the contracts with focused tests.

Verification: npm test in site (48 tests passed); npx astro build; browser checks at 1101px, 1100px, 952px, and 900px in English and Chinese.
2026-08-02 23:41:43 +08:00
SivanCola 42a21500f9 Merge pull request #7222 from esengine/fix/npm-alias-propagation
fix(release): tolerate npm alias propagation delay
2026-08-02 23:35:45 +08:00
LI Xinyu 3a06203b1c fix(cli): make -c shorthand resolve for --continue
BoolVar(cont, "c", ...) registers "c" as a long flag name, so -c failed
with "unknown shorthand flag: 'c' in -c" — silently swallowed by the
return-2 error path — while --c accidentally worked. Route both flag
sets (runAgent and chatREPL) through a shared BoolP registration so
-c/--continue behave as documented, and add regression tests.

Fixes #7156, #7171
2026-08-02 23:15:58 +08:00
SivanCola 75c4308810 fix(release): wait for npm alias propagation 2026-08-02 23:13:57 +08:00
SivanCola 37360241df Merge pull request #7219 from SivanCola/chore/migrate-npm-release-aliases
chore(release): align legacy npm aliases
2026-08-02 23:09:08 +08:00
SivanCola dfc9f19563 chore(release): align legacy npm aliases
Problem:
The bridge release advanced npm latest to 1.19.2 while the compatibility canary and next aliases still selected public prereleases.

Root cause:
The legacy Stable publisher owns latest only, and the single-channel finalizer is not active yet.

Fix:
Add a zero-input, release-approved one-time workflow that verifies all seven package records against the exact 1.19.2 candidate SHA before atomically aligning latest, canary, and next. Fail closed on provenance drift and verify every final alias.

Verification:
node --test scripts/finalize-npm-official-release.test.mjs
bash scripts/release-workflows.test.sh
go run github.com/rhysd/actionlint/cmd/[email protected] .github/workflows/migrate-npm-release-aliases.yml .github/workflows/ci.yml
git diff --check
2026-08-02 23:00:41 +08:00
SivanCola cea216a57a Make Desktop updates failure-atomic
Problem:
- current.json could degrade to an in-place copy, legacy helpers could reject valid pending updates without relaunching, overlapping window saves could reorder, and manual NSIS installs wrote live layout state directly.

Root cause:
- Commit-pointer writes shared a compatibility fallback, the detached helper resolved pending state from its cache location, frontend saves were concurrent, and the installer bypassed the layout activation transaction.

Fix:
- Add strict atomic pointer writes, claim legacy transactions against the explicit install root with recovery relaunch, serialize window capture/persistence, and route normal installers through signed staging activation.
- Add failure-injection, ordering, handoff, and packaging contract tests.

Verification:
- GOWORK=off go test ./... && GOWORK=off go vet ./... (root and desktop modules)
- GOWORK=off go test -race on affected root and desktop packages
- pnpm test:all && pnpm build
- Windows amd64 cross-compile for migrator, update helper, and desktop
- ./scripts/cache-guard.sh and cache impact check
2026-08-02 21:57:19 +08:00
SivanCola 876c3dff56 Remove remaining Safe Mode code paths
Problem:
- Product Safe Mode had been disabled semantically, but boot, theme, telemetry, settings, and startup tracking still carried always-false branches and compatibility fields.

Root cause:
- The initial Guard removal preserved obsolete behavior APIs instead of reducing them to the normal startup and appearance paths.

Fix:
- Flatten boot onto the normal integration path, remove Safe Mode fields and telemetry gates, and keep legacy startup state read-only for crash diagnostics.
- Remove obsolete frontend/theme contracts and update recovery documentation.

Verification:
- GOWORK=off go test ./... && GOWORK=off go vet ./... (root and desktop modules)
- GOWORK=off go test -race ./internal/boot ./internal/telemetry ./internal/repair ./internal/fileutil ./internal/installlayout
- pnpm test:all && pnpm build
- ./scripts/cache-guard.sh and cache impact check
2026-08-02 21:57:02 +08:00
SivanCola c46e3af1c2 Merge pull request #7214 from esengine/release-notes/v1.19.2
docs(release): 准备 v1.19.2 中英更新日志
2026-08-02 21:53:25 +08:00
SivanCola c2f4976815 docs(release): refine v1.19.2 release notes
Summary:
List all affected user-facing subsystems and correct two English descriptions in the generated stable release notes.

Verification:
Validate the release catalog, run its tests, render the exact public notes, scan them for private data, and check patch formatting.
2026-08-02 21:52:30 +08:00
github-actions[bot] 2a6064c75b docs(release): prepare v1.19.2 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-02 13:51:21 +00:00
jackie-cqz 9e9c818324 fix: add configurable terminal theme 2026-08-02 21:00:44 +08:00
SivanCola d49358fe8a Merge main-v2 Windows instruction CI guard
Integration: Incorporate the Windows instruction-home isolation and CI environment coverage without overlapping the reasoning guard or usage telemetry paths.

Verification: Root suite, instruction tests at count 10, focused Agent race/provider/Remote suites, cache guard, formatting, and diff checks.
2026-08-02 20:44:38 +08:00
SivanCola f72874d49b Merge pull request #7209 from SivanCola/fix/windows-instruction-home-test
test(instruction): isolate Windows home imports / 隔离 Windows Home 测试
2026-08-02 20:39:03 +08:00
SivanCola 7bcea588d6 Merge main-v2 into reasoning guard
Integration: Incorporate the official release-channel migration and preview release notes while preserving the additive estimated-usage fields in the shared frontend types.

Verification: Root tests, frontend tests and production build, focused Agent race/provider/cache suites, Desktop telemetry/workbench tests, and isolated repetitions of the unrelated flaky Desktop file and git probes.
2026-08-02 20:29:44 +08:00
SivanCola 6849308cef ci(windows): cover instruction home resolution
Summary:
Add the instruction package to the bounded Windows pull-request smoke suite so platform-specific home expansion is checked before merging.

Verification:
Run the instruction package, lint the CI workflow with actionlint v1.7.7, and check patch formatting.
2026-08-02 20:19:25 +08:00
SivanCola 6076a35731 test(instruction): isolate Windows home imports
Summary:
Set both Unix and Windows home environment variables in instruction resolver tests so tilde expansion remains inside each temporary test home.

Verification:
Run the instruction package once, repeat the three home-import cases 20 times, and check patch formatting.
2026-08-02 20:18:28 +08:00
SivanCola a7acd8a3f4 fix(desktop): keep runtime recovery actions reachable
Problem:
Detached runtimes without child jobs disappeared from the status bar, Remote Workbench jobs could be replaced by local groups, and displayed runtime decision shortcuts did not work.

Root cause:
The status chip treated jobs.length as the only activity signal and selected one data source. RuntimeDecisionCard only listened for Escape.

Fix:
Keep running and prompt-blocked runtimes visible, merge active remote jobs with local runtime groups, expose actionable runtime state copy, and route enabled number keys to their matching actions.

Verification:
Ran the full frontend suite and production build, both Go module suites, focused and Desktop race suites, cache guards, targeted DOM interaction tests, and a local browser smoke check.
2026-08-02 20:10:20 +08:00
SivanCola 2afbe59efc Merge pull request #7207 from esengine/release-notes/v1.19.2-preview.1
docs(release): 准备 v1.19.2-preview.1 中英更新日志
2026-08-02 20:09:59 +08:00
SivanCola 564c5a710b docs(release): correct v1.19.2 preview surfaces
Summary:
List every user-facing subsystem covered by the bridge release notes instead of classifying the release as Desktop-only.

Verification:
Validate the release catalog, render the exact public notes, scan the rendered text for private data, and check the patch formatting.
2026-08-02 20:07:41 +08:00
github-actions[bot] dc2fdebe97 docs(release): prepare v1.19.2-preview.1 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-02 12:06:15 +00:00
SivanCola d7229fe211 fix(agent): harden reasoning output budget telemetry
Problem: Runaway reasoning streams relied only on a client byte guard, reused the provider length reason, and exposed reconstructed usage as exact across public surfaces.

Root cause: The agent did not send a stable provider output budget, Usage had no estimate provenance, and the guard was named as a character limit even though strings.Builder.Len reports bytes.

Fix: Send a stable derived MaxTokens budget through every built-in protocol, use client_reasoning_limit for the local guard, rename the option to ReasoningByteLimit, and propagate Estimated through CLI, Desktop, ACP, event wire, doctor, and Remote Workbench contracts.

Verification: go test ./... in the root and Desktop modules; focused race tests; provider serialization tests; cache guard; Remote protocol generation tests; frontend tests and production build.
2026-08-02 20:04:37 +08:00
SivanCola ede7ceef00 Merge pull request #7201 from SivanCola/codex/single-channel-runtime
feat(update): migrate clients to the official release channel
2026-08-02 20:00:46 +08:00
SivanCola d8a4288862 fix(desktop): remove obsolete safe mode paths
Problem: v1.20 no longer enters product Safe Mode, but the desktop still carried unreachable startup recovery, theme gating, renderer fallback, and platform helper paths that kept lint red.

Root cause: the initial Guard removal disconnected the entry points without deleting their dormant implementations or moving Linux-only helpers behind the Linux build tag.

Fix: delete the obsolete recovery and renderer policy, simplify theme operations to the normal path, retain only ignored legacy launch tokens and always-false wire fields for upgrade compatibility, and clear the remaining Desktop lint findings.

Verification: go test ./...; go vet ./...; golangci-lint run ./...; pnpm typecheck; pnpm exec tsx src/__tests__/theme-pack.test.ts; Windows amd64 cross-compile.
2026-08-02 19:45:37 +08:00
SivanCola 9546b1f314 ci: refresh cache impact metadata 2026-08-02 19:39:20 +08:00
SivanCola cc38074026 feat(update): migrate clients to official release channel 2026-08-02 19:34:27 +08:00
SivanCola 5664dc8ba7 fix(desktop): clear Guard removal lint blockers
Problem:
Guard removal left two unused Safe Mode internals and a no-op legacy argument check, causing golangci-lint to fail.

Root cause:
The compatibility APIs were made constant-false but their obsolete storage/helper remained, while the migrator prefix branch only continued at the natural end of the loop.

Fix:
- Remove the unused Config.safeMode field and loadSafeModeForRoot alias.
- Remove the side-effect-free legacy prefix check; unknown compatibility arguments remain ignored.

Verification:
- go test ./internal/config ./cmd/reasonix-legacy-migrator
- go vet ./internal/config ./cmd/reasonix-legacy-migrator
- golangci-lint run ./internal/config ./cmd/reasonix-legacy-migrator
- CACHE_IMPACT_PR_BODY_FILE=/tmp/reasonix-v120-guard-removal-pr.md ./scripts/check-cache-impact.sh
- ./scripts/cache-guard.sh
- git diff --check
2026-08-02 19:22:16 +08:00
SivanCola d8c319d144 fix(desktop): harden Guard-free update migration
Problem:
The initial Guard removal still left old 1.18-1.19.1 clients without a reliable migration handoff, allowed corrupt current.json files to fall back to stale flat binaries, and kept legacy pending transactions in the versioned update path.

Root cause:
Launcher and migrator ownership was split, version activation did not publish stable root entries transactionally or serialize competing processes, and Windows/Linux versioned updates still inherited parts of the flat-layout recovery protocol.

Fix:
- Share permanent launcher behavior and run the one-shot compatibility migrator before resolving the active desktop.
- Finalize exact legacy pending transactions during flat-to-versioned migration and archive only strictly older install-local transactions after a healthy recovery install.
- Publish version directories and root launcher/CLI entries under a cross-process activation lock, with current.json as the final commit point and rollback on failure.
- Remove legacy pending identities from Windows versioned handoff and activate Linux versioned updates without persisting Guard.
- Make config file merging transactional, hold one update request owner across all phases, and align Linux deb/frontend contracts with the Guard-free product behavior.
- Add migration, rollback, packaging, concurrency, compatibility, and legacy environment regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- targeted go test -race for config, installlayout, repair, CLI, launcher, migrator, and updater paths
- go vet ./... in both Go modules
- pnpm test:updater and pnpm build in desktop/frontend
- cache guard and cache-impact checks
- Windows amd64 desktop/update-helper and Linux amd64 launcher/migrator/update-helper cross-compilation
2026-08-02 18:46:03 +08:00
SivanCola cf17cad099 fix(desktop): recover blocked delivery runtimes 2026-08-02 18:45:21 +08:00
SivanCola 51a7b83e28 fix(desktop): restore background job controls 2026-08-02 18:32:18 +08:00
SivanCola 9fede8b281 feat(desktop): finish v1.20 remaining fault tolerance, API and packaging
Problem:
v1.20 still lacked local config recovery, kept StartupTracker/old split update
bindings in the product surface, and packaged flat Windows/Linux layouts with
Guard assumptions.

Root cause:
LoadForRoot failed closed on corrupt TOML; desktop still bound download/install
as separate Wails methods; portable and Linux packaging still assumed a flat
Guard release unit; helper still allowed a flat publish fallback.

Fix:
- Config load: corrupt user config uses last-known-good in memory (else
  built-in defaults) without rewriting the original file; project config and
  MCP JSON failures are isolated with LoadWarnings.
- Desktop surfaces configWarnings with Open config / Reload / doctor repair
  banner actions; records LKG after UI ready and on clean shutdown.
- Remove StartupTracker from launch control (observe previous run for crash
  diagnostics only).
- Wails update surface is ApplyUpdateRequest only; download/install are private.
- Helper versioned-v1 activation is mandatory for staged Windows payloads.
- Windows portable packaging/verification and Linux tarball use versioned/thin
  launcher contracts; macOS bundle rejects Guard.
- Fault-injection and legacy flat migration fixture tests for installlayout.

Verification:
- go test ./internal/config ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/ and focused package tests
- pnpm --dir desktop/frontend test:updater (31 passed)

Out of scope for local automation: Windows DPI 100x soak, Preview 72h gate,
production migration success rate, and live signed-installer UI coverage.
2026-08-02 18:05:26 +08:00
SivanCola 8d7764c61a Merge main-v2 into agent reasoning guard
Integration: preserve the latest persisted missing-reasoning warning state while retaining the PR reasoning stream guard.

Verification: focused runaway, interrupted-usage, and missing-reasoning warning tests passed.
2026-08-02 17:54:31 +08:00
SivanCola 79449ef801 feat(desktop): versioned helper activation, drop Guard, NSIS dual layout
Problem:
The Windows update helper still published a flat six-member release unit with
Guard health recovery. The Guard recovery product remained as a first-class
binary and packaging still installed Guard as the permanent entry point.

Root cause:
Post-stage publish was hard-wired to flat InstallRoot members and pending
receipt recording. Packaging and docs still treated reasonix-guard as the
launcher/recovery surface.

Fix:
- Prefer versioned-v1 activation from staged NSIS payloads: write
  versions/<ver>/{desktop,cli,helper}, swap current.json, place thin launcher
  and portable alias at InstallRoot, remove flat desktop/guard leftovers.
- On versioned success, clear the pending transaction without health probation
  and relaunch via reasonix-launcher.
- Resolve InstallRoot and update-helper source for versioned trees; claim
  pending targets from the prepared transaction rather than hard-coded flat
  paths.
- NSIS dual mode: /REASONIXSTAGE=1 still extracts the six-member flat payload
  for 1.18-1.19.1 helpers; normal install writes versioned layout and does not
  persist Guard.
- Delete cmd/reasonix-guard. Compatibility payloads still build
  reasonix-guard from reasonix-legacy-migrator only. Point recovery docs at
  reasonix doctor.
- Linux .desktop Exec uses reasonix-launcher.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/
- focused desktop packaging/window/updater tests
- GOOS=windows go test -c ./cmd/update-helper and desktop package
2026-08-02 17:54:11 +08:00
SivanCola 0e78ef425e feat(desktop): start v1.20 Guard removal and versioned update layout
Problem:
Desktop shutdown can panic on Windows when Wails queries window geometry after DPI reports 0. Product Safe Mode and Guard health policy force global capability loss and leave users stuck on pending-update loops. The flat install layout cannot support atomic version activation.

Root cause:
beforeClose/shutdown called WindowGetSize/Position/IsMaximised on a tearing-down WebView2 surface. StartupTracker and REASONIX_SAFE_MODE drove a process-wide degraded mode. Updates relied on a single pending transaction with health probation and Guard rollback.

Fix:
- Persist window state only from frontend reports; Go rewrites last-known state without native queries during hide/shutdown. Accept Windows border origin x=-8,y=-8 and add regression coverage.
- Introduce installlayout (current.json schema 1, staging activation, version GC) plus a permanent thin reasonix-launcher and a one-shot legacy migrator (compat binary name reasonix-guard).
- Add ApplyUpdateRequest single-action update path, install_layout field validation, and frontend update-and-restart UX.
- Make SafeModeRequested always false; strip desktop startup recovery that forced Safe Mode; keep doctor/crash diagnostics non-gating.
- Point desktop-build Windows launcher/migrator builds at the new cmds.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher ./cmd/reasonix-legacy-migrator ./internal/config ./internal/boot
- focused desktop window-state/updater/safe-mode tests
- pnpm --dir desktop/frontend test:updater (31 passed)

Remaining for full v1.20.0 gate: NSIS dual-mode packaging, versioned payload activation in update-helper, delete cmd/reasonix-guard source, full config last-known-good resilience, macOS/Linux packaging and release docs.
2026-08-02 17:43:03 +08:00
SivanCola 0d42ffbbdc Merge pull request #7109 from sudoviber/fix/7059-suppress-tool-call-reasoning-warning
fix(agent): rate-limit missing tool-call reasoning warnings / 按配置限频工具调用思考告警
2026-08-02 16:57:35 +08:00
paradoxSCH 70097ea087 fix(agent): guard runaway reasoning usage 2026-08-02 15:00:59 +08:00
SivanCola 1c62489dac Merge pull request #7159 from SivanCola/fix/theme-safe-code-highlighting
Keep syntax highlighting readable across themes / 保持不同主题下的代码高亮清晰
2026-08-02 09:22:09 +08:00
SivanCola cb74987bd9 Merge pull request #7146 from Q1hangL/fix/delivery-readiness-verifier-summary
fix(agent): guide delivery readiness verification recovery / 引导 Delivery 就绪验证恢复
2026-08-02 09:20:57 +08:00
SivanCola 3add2995d8 fix(agent): preserve warning resolution ordering
Problem:
Delayed missing-reasoning transactions could revive an incident after a newer healthy turn. Future last-missing timestamps and transient healthy-state write failures could then suppress a later regression despite the warning contract.

Root cause:
The persisted record kept only missing timestamps and deleted healthy incidents, so it had no resolution watermark to reject stale claims. State loading validated only the warning timestamp, and resolve writes discarded persistence errors.

Fix:
Keep a bounded nanosecond resolution watermark in the additive v2 state, reject inconsistent or future event timestamps, and keep non-missing read failures visible. Preserve failed healthy resolutions in the agent and retry them before the next missing claim, falling back to a visible warning if persistence is still unavailable. Add ordering, recovery, migration, and partial-read regression coverage.

Verification:
- go test -count=1 ./...
- go test -race -count=1 ./internal/agent
- go test -count=1 ./... (desktop module)
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh origin/main-v2
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 09:05:12 +08:00
SivanCola 1fd5d7bb99 fix(evidence): reject non-checking tsc modes
Problem:
TypeScript commands such as tsc --noEmit --init could write tsconfig.json while being treated as non-mutating verification. Metadata, watch, build, and --noCheck modes could also satisfy or stall readiness without completing a bounded type check.

Root cause:
The TypeScript verifier only required --noEmit and rejected a small set of independent output destinations. It did not distinguish ordinary compiler checks from control and early-exit modes.

Fix:
Fail closed on init, help/version/config display, list-only, no-check, watch, build/clean, trace/profile, and explicit build-info destinations for both direct tsc and npx tsc. Preserve ordinary one-shot no-emit checks and add positive and negative classifier regressions.

Verification:
- go test ./internal/evidence -run "VerificationCommandSummary|GoBuild|TSCVerification|PythonCompileall|NpxVerification|RunnerWriteOutput" -count=1
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check
2026-08-02 09:03:00 +08:00
SivanCola 85de763b42 fix(desktop): guarantee contrast across diff surfaces
Problem:
Valid midtone custom themes could make semantic or syntax colors fail WCAG AA on either the plain code surface or a tinted diff row.

Root cause:
A fixed diff tint could cross the black/white contrast boundary, leaving no shared foreground that reached 4.5:1 across every rendered surface.

Fix:
Reduce semantic row tint only when required, validate every code role against plain, added, and deleted backgrounds, and add adversarial plus generated-palette regressions.

Verification:
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- go test ./...
- go test -race ./internal/cli -count=1
- cd desktop && go test ./...
2026-08-02 09:00:58 +08:00
SivanCola 2e9680edd0 Merge pull request #7164 from SivanCola/fix/mcp-startup-recovery
fix(mcp): keep slow startup alive in background / 修复 MCP 慢启动反复重启
2026-08-02 08:42:37 +08:00
SivanCola 4a989f4d6a fix(mcp): flush startup stderr before reporting
Problem:
The Linux race job intermittently observed an initialize timeout before the stdio stderr copy goroutine had flushed, so structured startup diagnostics omitted the expected redacted stderr tail.

Root cause:
Startup failure paths sampled the bounded stderr buffer before closing and reaping the transport, even though transport close is the synchronization point for the copy goroutine.

Fix:
Close the failed transport first, then read and redact startup stderr for initialize and both tools/list paths.

Verification:
- go test -race ./internal/plugin -run ^TestStartupFailureReportsStageElapsedAndRedactedStderr$ -count=20
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- go test -p 4 ./... -count=1
- go vet ./internal/plugin
- ./scripts/cache-guard.sh
- git diff --check
2026-08-02 08:35:39 +08:00
SivanCola 1f3a67b57e fix(agent): serialize warning state transactions
Problem:
After same-incident coalescing fixed duplicate Windows warnings, concurrent claims for different configurations could still lose persisted incidents.

Root cause:
Each fingerprint had its own leader, so distinct leaders still competed in the bounded in-process portion of the file lock. Slow Windows atomic writes let queued leaders time out before their read-modify-write transaction.

Fix:
Serialize all transactions for one warning-state file with a process-local mutex before entering the bounded cross-process file lock. Same-fingerprint flights still coalesce duplicate notices, while distinct configurations now complete lossless read-modify-write transactions.

Verification:
- go test ./internal/agent -run TestMissingReasoningWarnStateConcurrent -count=100
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/agent

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:23:28 +08:00
SivanCola be1d9c359c fix(evidence): reject npx runner aliases
Require npx verifier identities to use a bare known package name with only an ordinary version or dist-tag suffix. Reject filesystem paths and package protocols so aliases such as eslint@npm:other-package cannot inherit the trusted runner classification.
2026-08-02 03:20:19 +08:00
SivanCola e94e549799 fix(mcp): fence background startup removal
Problem:
A server removal could race the narrow gap between a background handshake finishing tools/list and publishing its client, allowing the removed server to reappear.

Root cause:
EnsureConnectedInBackground registered a deferred generation but did not pass that generation into the final Host publication check.

Fix:
Carry the generation through the shared connection path, retain configured server names in connection status, and cover removal during an in-flight background startup.

Verification:
- go test ./internal/plugin -count=1
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- go test -p 4 ./... -count=1
- git diff --check
2026-08-02 03:15:34 +08:00
SivanColaandipanta 1522bac58d fix(evidence): harden verifier output boundaries
Problem:
Delivery recovery still accepted bytecode compilation and TypeScript diagnostic output as non-mutating verification. The npx policy also recognized only two test runners, leaving safe test and lint commands from #6901 behind the Auto Guard boundary.

Root cause:
The verifier allowlist treated compileall as a check, only gated tsc on noEmit, and did not distinguish safe npx test/check modes from output-producing runners and flags.

Fix:
Classify compileall and explicit TypeScript build-info, trace, and CPU-profile destinations as mutations. Adapt the known-runner direction from #6901 for Mocha, AVA, ESLint, Prettier check mode, and no-emit npx tsc while keeping Playwright, Cypress, source executors, control flags, and output modes fail closed.

Verification:
- go test ./internal/evidence -run 'VerificationCommandSummary|GoBuild|TSCVerification|PythonCompileall|NpxVerification|RunnerWriteOutput' -count=1
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check

Co-authored-by: ipanta <[email protected]>
2026-08-02 03:14:11 +08:00
SivanCola 558fb5d474 Merge latest main-v2 into PR #7109
Refresh the Windows concurrency repair after main-v2 advanced with v1.19.1 release notes. The base-only documentation update does not overlap executable code.
2026-08-02 03:11:30 +08:00
SivanCola 969f7cd436 fix(agent): coalesce concurrent reasoning incidents
Problem:
Windows CI could emit several first warnings for one concurrent incident even though persistent state used a file lock.

Root cause:
The bounded file-lock deadline also covered the in-process queue. On slower Windows atomic writes, followers timed out locally and each took the fail-visible path.

Fix:
Coalesce in-process claims by state path and configuration fingerprint. Followers suppress duplicate warnings while forwarding their latest observation timestamp to the leader, which preserves stale-health ordering and retains the bounded cross-process lock behavior.

Verification:
- go test ./internal/agent -run TestMissingReasoningWarnStateConcurrent -count=50
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/agent

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:11:06 +08:00
SivanCola 5c16cc1408 fix(mcp): keep slow startup alive in background
Problem:
Cold MCP servers that exceeded the short interactive wait were canceled and restarted on every retry, obscuring the real startup stage and source.

Root cause:
The caller wait context also owned the shared initialize and tools/list lifecycle, so a five-second timeout terminated otherwise healthy cold starts.

Fix:
Separate the session-owned startup lifecycle from the caller wait, add bounded global and per-server startup timeouts, preserve pinned provider-visible tool schemas, and expose redacted structured diagnostics.

Verification:
- go test -p 4 ./... -count=1
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- ./scripts/cache-guard.sh
- go vet ./internal/plugin ./internal/config ./internal/boot ./internal/capdiag ./internal/control ./internal/agent ./internal/installsource
- git diff --check
2026-08-02 03:07:24 +08:00
SivanCola 3ad7439f92 Merge latest main-v2 into PR #7109
Refresh the repaired warning-state branch after the base advanced during verification. The intervening billing, remote protocol, and release-note changes do not overlap the incident-state implementation.
2026-08-02 03:00:33 +08:00
SivanCola 86a4958057 fix(agent): re-arm missing reasoning diagnostics
Problem:
Persisting a provider-name-only acknowledgment hid endpoint/model changes and future regressions, while OpenAI-format V4 Flash was inconsistent with the current DeepSeek thinking contract.

Root cause:
Warning state had no configuration identity, expiry, or healthy recovery transition, and the OpenAI warning matcher excluded the whole Flash family.

Fix:
Key incidents by an opaque adapter configuration fingerprint, expire them after 24 hours, clear them after healthy tool-call reasoning, and preserve ordering with millisecond event timestamps under the existing cross-process lock. Align known DeepSeek thinking models across OpenAI, Anthropic, and Responses adapters and use protocol-neutral diagnostics.

Verification:
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:00:14 +08:00
SivanCola 3c5e51a214 Merge origin/main-v2 into fix/theme-safe-code-highlighting
Synchronize the latest release, desktop packaging, billing, and remote protocol changes before publishing the theme readability integration. The upstream paths do not overlap the theme or syntax-highlighting changes.
2026-08-02 02:54:51 +08:00
SivanColaandHaoyueQin 10c79d6e8b fix(desktop): preserve code contrast across pane themes
Problem:
Theme packs, Creation overrides, and diff tint layers could leave syntax or semantic diff text below WCAG AA, while the extended pane transparency rules could make protected code surfaces translucent again.

Root cause:
Base styles did not install complete code palettes, Creation replaced only the code background, diff contrast was measured against the untinted surface, and the pane-tier integration treated block code as an ordinary child panel.

Fix:
Generate complete opaque palettes for every base style and Creation, derive foregrounds against final diff row backgrounds, share the derivation with previews, exempt block code and diffs from pane transparency, and clear every extended pane variable.

Verification:
- pnpm test
- pnpm build
- go test ./...
- go test -race ./...
- in-app browser light, dark, and background-theme contrast checks

Co-authored-by: HaoyueQin <[email protected]>
2026-08-02 02:54:28 +08:00
SivanCola 49fad9d6f8 Merge main-v2 into PR #7109
Bring the warning-state fix onto the current provider and agent baseline before refining its persistence semantics. This keeps the contributor branch mergeable without rewriting its public history.
2026-08-02 02:47:28 +08:00
SivanCola bf8490df5b fix(evidence): fail closed on output-capable verifiers
Problem:
Go build package patterns can still write a workspace binary when they expand to one main package. The recovery summary also rendered only family labels, hiding safe examples such as tsc --noEmit while the classifier accepted output-capable bare tsc commands.

Root cause:
The static classifier assumed ./... always implied discarded build outputs, and the summary discarded the same concrete examples used by its consistency tests. TypeScript verification was not gated on no-emit mode.

Fix:
Classify every go build form as a mutation, render each tested safe command example in the recovery summary, and require explicit TypeScript no-emit mode. Update the serial sign-off scenario and add regressions for Go build, TypeScript output flags, and model-visible examples.

Verification:
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:46:51 +08:00
HaoyueQin 9df2ec56d9 feat(desktop): complete theme pane transparency, hover consistency, and dock-tab compression
- Five-tier pane transparency system (Shell / Card / Session-hover /
  Child / Interact) with CSS variables driven by paneOpacity slider
- Sidebar session items (project-tree__topic) and folders now share
  the same session-hover tier, responding to panel opacity uniformly
- Welcome page example buttons (welcome__ex) placed on child tier,
  one level above the welcome container background
- Topicbar buttons + external-opener unified on card tier
- Update banner (banner--update) raised to child tier
- Active dock-tab underline moved inside button (bottom:-1px→1px)
  so it is no longer clipped by content below
- Windows frameless dock-tab compression: scoped to workbench layout
  only, compresses spacing (padding / gap) via clamp() while never
  shrinking tabs or truncating labels

(cherry picked from commit fad7908b0f)
2026-08-02 02:43:24 +08:00
SivanCola b0d7805b02 fix(desktop): protect syntax contrast across themes
Problem:
Code and diff surfaces inherited translucent theme backgrounds while syntax colors followed only the global light/dark mode. Custom backgrounds could therefore bleed through and make highlighted text unreadable.

Root cause:
Theme-pack overlays forwarded bgSoft alpha directly and had no derived code surface or per-token contrast guard. The theme preview also omitted realistic code and diff content.

Fix:
Derive an opaque code background and border from each theme mode, choose the syntax direction from the final surface luminance, and minimally adjust every code and diff text role to WCAG AA contrast. Route code, full diff, and inline diff rendering through the protected surface, and add live code/diff samples to theme previews.

Verification:
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- cd desktop && go test ./...
- go test ./...
- go test -race ./internal/cli -count=1
- in-app browser: light/dark, home/task, official background, and #RRGGBBAA custom bgSoft
2026-08-02 02:29:43 +08:00
SivanCola 484263db3f fix(cli): follow theme mode for diff syntax colors
Problem:
CLI diffs kept the GitHub dark syntax palette on light terminal themes, leaving pale tokens difficult to read on light diff backgrounds.

Root cause:
The Chroma style was resolved once as github-dark instead of following the active CLI light or dark mode during rendering.

Fix:
Resolve the registered GitHub light/dark counterpart on every render so runtime theme switches and theme-sweep preview frames use matching syntax colors. Add regression coverage for style selection, live output changes, and source-text preservation.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli -count=1
- go test ./...
2026-08-02 02:29:43 +08:00
SivanCola fa8b24caa5 fix(evidence): parse go build verification flags
Problem:
The verification classifier searched raw go build arguments for ./..., allowing flag values such as -pkgdir ./... to masquerade as package operands.

Root cause:
Build flags and their values were not separated from package operands.

Fix:
Parse documented Go build flags, accept only safe forms, require ./... among actual package operands, and fail closed for output-affecting or unknown flags. Add regressions for safe -tags forms and flag-value bypasses.

Verification:
- go test ./...
- go vet ./...
- go test -race ./internal/evidence ./internal/agent
- bash scripts/cache-guard.sh
- runtime go build flag checks with unchanged workspace status

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:24:00 +08:00
SivanCola 6afdb6805b test(agent): satisfy warning-state static analysis
Split the repeated in-memory fallback claim assertion so staticcheck does not
treat two stateful calls as an identical boolean expression.

Verification:
- golangci-lint run --timeout=5m
- go test -race ./internal/agent
- git diff --check
2026-08-02 02:09:51 +08:00
SivanCola 643916d687 fix(agent): harden readiness verifier guidance
Problem:
The readiness recovery summary advertised direct go build commands that can create workspace binaries, appeared on unrelated readiness failures, and duplicated classifier examples in a test-only list.

Root cause:
The summary treated every accepted verifier as equally safe to recommend and was appended without consulting the structured readiness failure. Its drift test did not share the data used to render the summary.

Fix:
Recommend a structured safe subset, classify only all-package go build patterns as non-mutating verification, and inject the summary only when verification evidence is actually missing. Add regression coverage for workspace-output builds, recommendation consistency, and conditional injection.

Verification:
go test ./...
go vet ./...
go test -race ./internal/evidence ./internal/agent
bash scripts/cache-guard.sh
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:06:28 +08:00
SivanCola 165c43fc5b fix(agent): serialize persisted reasoning warning claims
Problem:
Separate agent instances cached the warning state independently and used a fixed temporary file, so concurrent claims could duplicate a warning or overwrite another provider.

Root cause:
The read-check-write sequence was not one cross-process transaction.

Fix:
Serialize each claim with a bounded cross-process file lock, reread the state under that lock, and publish the merged provider set with the existing atomic writer. Persistence failures preserve the historical once-per-session warning behavior.

Verification:
- go vet ./...
- go test -race ./internal/agent
- go test ./...
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:01:50 +08:00
Q1hangL 1d0305478a fix(agent): break delivery readiness deadlock by exposing verifier whitelist to the model
Delivery mode blocks final answers until a host-recognized verification
command is cited in complete_step, but the whitelist classifier
(bashSegmentIsVerification) is invisible to the model. The two most
intuitive guesses both fail deterministically: inline interpreters
(node -e / python -c) are blocked as opaque mutations, and read-only
inspection pipelines (grep|wc, find) never count as verification.
The model loops until maxFinalReadinessBlocksWithProgress=6 or the turn
is force-interrupted - observed 7 readiness injections in one CLI
session and reproduced locally.

Add evidence.VerificationCommandSummary() and embed it in both the
final-readiness retry message and the opaque-interpreter block message,
so the model can pick a valid verifier (node --check, project test
runners, read-only pipelines into a verifier) on the first retry.

Tests: summary/classifier consistency (anti-drift), deadlock-trap
warnings, read-only pipeline escape hatch, retry-message content.
Verified locally with go1.26.5: gofmt clean, go test ok.
2026-08-01 21:23:41 +08:00
sudoviber 5447d1c5a6 fix(agent): show missing tool-call reasoning warning once per provider
DeepSeek (and some gateways) never attach reasoning_content to tool_calls
turns, so the once-per-session notice re-appeared on every new session and
--resume even though its text promised a single showing (#7059).

Persist the warned-provider set to <reasonix home>/state via a new
missingReasoningWarnState store (atomic temp-file+rename, best-effort:
any IO failure degrades to the historical once-per-session behavior) and
check it in warnMissingToolCallReasoning before emitting. Boot supplies
the shared state dir to both executor and planner options; direct Agent
construction without the option keeps the old scope, so nothing changes
for embedders that do not opt in.

Update the notice text to "shown once" and add unit tests (persistence,
sorting, corrupt-file self-heal, empty-dir no-op) plus an e2e test
covering SetSession and fresh-agent runs over a shared state dir.
2026-08-01 05:56:36 +08:00
646 changed files with 52572 additions and 9390 deletions

No files matched your search

-3
View File
@@ -7,10 +7,7 @@
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-preview.yml @SivanCola @esengine
/.github/workflows/release-cli-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
+10
View File
@@ -15,6 +15,16 @@ release workflow then asks that reporter to verify once the fix ships.
-
## Documentation impact
Documentation-impact: TODO
For changes to user-visible CLI, Desktop, configuration, provider, permission,
or tool behavior, use one of:
- `Documentation-impact: updated - <what changed>` and update `docs/*.md`.
- `Documentation-impact: none - <why the embedded documentation remains correct>`.
## Cache impact
Cache-impact: TODO
+32 -19
View File
@@ -79,7 +79,7 @@ jobs:
uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -129,15 +129,16 @@ jobs:
run: go test ./...
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, plus cmd/. The full ./... sweep stays on
# pushes to main-v2; the Unix legs always run the full suite.
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 10
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
@@ -152,6 +153,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
- name: test (Scoop desktop launch)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
timeout-minutes: 10
shell: powershell
run: .\scripts\test-scoop-desktop-launch.ps1
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
@@ -159,7 +166,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -204,18 +211,18 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -259,6 +266,9 @@ jobs:
- name: Test integrated terminal frontend
run: pnpm --dir frontend test:terminal
- name: Test usage statistics frontend
run: pnpm --dir frontend test:usage-stats
- name: vet
run: go vet ./...
@@ -287,7 +297,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -311,18 +321,18 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -364,7 +374,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -398,12 +408,15 @@ jobs:
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/release-preview.yml \
.github/workflows/release-cli-trigger.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-stable-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
.github/workflows/release-desktop.yml \
.github/workflows/release-verify-issues.yml \
.github/workflows/docs-impact.yml
bash scripts/release-workflows.test.sh
node scripts/check-single-release-public-contract.mjs
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
@@ -419,7 +432,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
@@ -434,7 +447,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -452,7 +465,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+2 -2
View File
@@ -40,11 +40,11 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.37.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.37.4
with:
category: "/language:${{ matrix.language }}"
+2 -2
View File
@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
+2 -2
View File
@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
+39
View File
@@ -0,0 +1,39 @@
name: Documentation impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
permissions:
contents: read
pull-requests: read
concurrency:
group: docs-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
docs-impact:
runs-on: ubuntu-latest
steps:
# Execute only the trusted base-branch checker; never run contributor code
# from a pull_request_target workflow.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/docs-impact-files.txt"
- name: Check documentation impact declaration
env:
PR_BODY: ${{ github.event.pull_request.body }}
DOCS_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/docs-impact-files.txt
run: bash scripts/check-docs-impact.sh
+37 -6
View File
@@ -46,15 +46,27 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true
- name: Build harness + fallback agent from the default branch
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
# its own grader or tests. The agent is rebuilt from the PR head below; this
@@ -93,10 +105,12 @@ jobs:
echo "agent under test: $src"
- name: Write provider config
env:
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
# User config covers suite tasks (they run in temp dirs); the repo-root copy
# covers diff mode (the agent runs in the repo root, where project config wins).
run: |
mkdir -p ~/.config/reasonix
mkdir -p "$REASONIX_HOME"
cat > /tmp/reasonix-e2e.toml <<EOF
default_model = "e2e"
@@ -120,19 +134,25 @@ jobs:
[codegraph]
enabled = false
EOF
cp /tmp/reasonix-e2e.toml ~/.config/reasonix/config.toml
cp /tmp/reasonix-e2e.toml "$REASONIX_HOME/config.toml"
cp /tmp/reasonix-e2e.toml ./reasonix.toml
- name: Run e2e
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [ -z "$DEEPSEEK_API_KEY" ]; then
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
exit 0
exit 1
fi
# Provider credentials are deliberately loaded only from Reasonix's
# global credential file, not inherited process variables. Keep this
# ephemeral runner copy private and outside the checked-out project.
umask 077
printf 'DEEPSEEK_API_KEY=%s\n' "$DEEPSEEK_API_KEY" > "$REASONIX_HOME/.env"
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
@@ -142,10 +162,21 @@ jobs:
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
else
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
# The current five-task baseline can exceed 400k after only three
# successful tasks. Keep bounded headroom so every scenario is graded.
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 800000
node -e '
const results = require("./report.json");
const unsuccessful = results.filter((result) => !result.Passed || result.Skipped);
if (results.length === 0 || unsuccessful.length > 0) {
console.error(`e2e suite incomplete: ${unsuccessful.length}/${results.length} unsuccessful`);
process.exit(1);
}
'
fi
- name: Post report
if: always() && hashFiles('report.md') != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRIGGER_USER: ${{ github.event.comment.user.login }}
+19 -4
View File
@@ -22,14 +22,29 @@ jobs:
with:
script: |
const body = context.payload.issue.body || '';
// Pull the "Version line" section out of the rendered issue form.
const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || '';
const m = section.match(/\bv([12])\b/i);
const sectionOf = (name) =>
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
const m = sectionOf('Version line').match(/\bv([12])\b/i);
if (!m) {
core.info('No version line found; nothing to label.');
return;
}
const choice = 'v' + m[1];
let choice = 'v' + m[1];
// The dropdown is a choice, and v2 sits first, so it gets picked by
// reporters who are actually on 0.x. `Exact version` is pasted from
// `reasonix --version`, so when it parses it is the better evidence:
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
if (exact) {
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
if (fromVersion !== choice) {
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
choice = fromVersion;
}
}
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -23,6 +23,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@v7
with:
sync-labels: false
+24 -55
View File
@@ -1,20 +1,12 @@
name: Prepare release notes
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Exact release version without a tag prefix (for example 1.18.0 or 1.19.0-preview.3)"
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
from_tag:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
target_pr:
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
required: false
type: string
permissions:
contents: write
@@ -29,58 +21,31 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
VERSION: ${{ inputs.version }}
TARGET_PR: ${{ inputs.target_pr }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [ -n "$TARGET_PR" ]; then
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::target_pr must be a positive pull request number"
exit 1
fi
mapfile -t pr_state < <(
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
--json state,baseRefName,headRefName,isCrossRepository \
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
)
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
echo "::error::target_pr must be an open pull request into main-v2"
exit 1
fi
if [ "${pr_state[3]}" != "false" ]; then
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
exit 1
fi
branch="${pr_state[2]}"
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
exit 1
fi
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
git merge --no-edit origin/main-v2
else
branch="release-notes/v${VERSION#v}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_PR=" >> "$GITHUB_ENV"
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
@@ -90,12 +55,9 @@ jobs:
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
FROM_TAG: ${{ inputs.from_tag }}
run: |
set -euo pipefail
args=(--version "$VERSION")
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
node scripts/generate-release-notes.mjs "${args[@]}"
node scripts/generate-release-notes.mjs --version "$VERSION"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
@@ -121,12 +83,19 @@ jobs:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if [ -n "$RELEASE_NOTES_PR" ]; then
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
-39
View File
@@ -1,39 +0,0 @@
name: Relay CLI prerelease
run-name: Relay CLI prerelease ${{ github.ref_name }}
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
# carries only the immutable candidate tag and its classified channel into the
# protected release control plane.
on:
push:
tags: ["v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected CLI control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
const tag = process.env.RELEASE_TAG;
const preview = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: preview ? 'release-preview.yml' : 'release.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: preview ? { tag } : { channel: 'stable', tag },
});
@@ -1,43 +0,0 @@
name: Relay desktop prerelease
run-name: Relay desktop prerelease ${{ github.ref_name }}
# Keep the production signing build on protected main-v2. The tag-triggered
# relay carries only the immutable candidate tag into the protected workflow.
on:
push:
tags: ["desktop-v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected desktop control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-desktop.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-desktop.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel: 'stable',
tag: process.env.RELEASE_TAG,
},
});
+44 -27
View File
@@ -1,10 +1,8 @@
name: Release desktop
# Desktop (Wails) release line. Stable releases are called by
# release-stable.yml after its single GitHub environment approval. Desktop
# prerelease tags are relayed by release-desktop-trigger.yml to this workflow on
# protected main-v2. Production SignPath requests therefore always have the
# exact main-v2 origin; both public channels use the production policy.
# Desktop (Wails) release line. Official releases are called by
# release-stable.yml after its single GitHub environment approval. Production
# SignPath requests therefore have the exact protected main-v2 origin.
#
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
@@ -12,27 +10,21 @@ name: Release desktop
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. Approved
# orchestrators publish Stable or Preview, while manual dispatch supports
# Stable/RC recovery and non-publishing Preview signing checks. Preview advances
# only R2's preview/ pointer and never appears on the GitHub releases page. The
# legacy canary/ pointer is mirrored for clients released before the rename.
# Historical workflow-call inputs still understand Preview artifacts so old
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
# create a new public Preview release.
on:
workflow_dispatch:
inputs:
channel:
description: "Release channel"
description: "Recovery channel"
type: choice
options: [stable, preview]
options: [stable]
default: stable
tag:
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "Preview signing checks: intended next stable version, e.g. 1.5.0"
required: false
type: string
production_signing_smoke:
description: "Wait for external SignPath approval, verify trust, and do not publish"
required: false
@@ -73,12 +65,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
@@ -213,7 +205,7 @@ jobs:
# execute, including during recovery of an older candidate.
ref: ${{ github.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -261,7 +253,7 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -269,6 +261,17 @@ jobs:
- name: Cache hit guard
run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
build:
name: build (${{ matrix.name }})
needs: [resolve, cache-guard]
@@ -299,16 +302,16 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
@@ -501,12 +504,26 @@ jobs:
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
run: cp signed-installer/*installer*.exe dist/
# Recovery builds intentionally check out the immutable product SHA,
# which can predate a verifier repair. Fetch only the verifier from the
# protected workflow commit so recovery can validate old immutable
# artifacts without rebuilding them from a moving product branch.
- name: Checkout protected release verifier
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
path: release-control
sparse-checkout: scripts/verify-windows-authenticode.ps1
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Verify Windows Authenticode release contract
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
shell: pwsh
run: |
$arch = if ("${{ matrix.platform }}" -eq "windows/arm64") { "arm64" } else { "amd64" }
./scripts/verify-windows-authenticode.ps1 `
./release-control/scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signed-payload `
-InstallerPath "dist/Reasonix-windows-$arch-installer.exe" `
-PortableArchivePath "dist/Reasonix-windows-$arch.zip" `
@@ -569,13 +586,13 @@ jobs:
VERIFY_RELEASE_CHECKOUT: true
run: bash release-control/scripts/resolve-desktop-candidate.sh
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
@@ -712,7 +729,7 @@ jobs:
path: release-control
ref: ${{ github.workflow_sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: release-control/desktop/go.mod
cache: true
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
+47 -26
View File
@@ -1,23 +1,11 @@
name: Release npm
# npm line. Stable releases are called by release-stable.yml after its single
# GitHub environment approval. Direct tag pushes are retained only for
# prereleases so npm `next` can still ship independently without touching the
# stable Homebrew cask. Bump a prerelease with:
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# Manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Public canary publication is owned by release-preview.yml so its
# version remains aligned with CLI, Desktop, and the immutable release marker.
# build.mjs decides the dist-tag: a `-canary.` build is `canary`, any other
# prerelease is `next`, and a stable version is `latest` (#5822 — the old
# "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The publisher verifies every immutable package and
# advances each public dist-tag only when the candidate is newer.
# GitHub environment approval. Direct prerelease tag publication is disabled;
# manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Historical canary and next package identities remain readable,
# but normal publication advances only the official release.
on:
push:
tags: ['npm-v*-*']
workflow_dispatch:
inputs:
channel:
@@ -64,12 +52,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
@@ -78,8 +66,8 @@ permissions:
contents: read
concurrency:
# Serialize every publisher for one npm dist-tag. In particular, canary
# remains ordered even if another approved orchestrator is added later.
# Serialize every publisher for one npm dist-tag. Historical canary calls
# remain ordered even though normal publication now uses Stable only.
group: release-npm-${{ inputs.channel || 'next' }}
cancel-in-progress: false
@@ -132,11 +120,23 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: v${{ inputs.base_version }}
DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
revision="$DOCS_SOURCE_REVISION"
if [ -z "$revision" ]; then revision="$(git rev-parse HEAD)"; fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$revision"
npm:
name: publish npm packages
@@ -149,17 +149,27 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
- name: Load approved standalone recovery control plane
if: ${{ !inputs.orchestrated }}
env:
RECOVERY_CONTROL_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$RECOVERY_CONTROL_SHA"
git restore --source="$RECOVERY_CONTROL_SHA" -- \
npm/publish.mjs \
scripts/finalize-npm-official-release.mjs
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. The Preview orchestrator synthesizes a
# <base>-canary.<preview_number> version; a manual Stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
# Stable publication uses the exact npm-v* tag. build.mjs strips the
# leading `npm-`/`v`; historical Preview inputs remain only for old
# workflow-call compatibility and are not reachable from a public entry.
- name: Resolve version
id: ver
env:
@@ -183,3 +193,14 @@ jobs:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
candidate="$APPROVED_SHA"
if [ -z "$candidate" ]; then candidate="$(git rev-parse HEAD)"; fi
EXPECTED_SHA="$candidate" node scripts/finalize-npm-official-release.mjs "$VERSION"
-219
View File
@@ -1,219 +0,0 @@
name: Release preview
run-name: Release preview ${{ inputs.tag }}
# One protected Preview event owns the public CLI, Desktop, npm, and changelog
# identities. The CLI tag supplies the canonical preview ordinal; child
# publishers retain their native version syntax but use that same ordinal.
on:
workflow_dispatch:
inputs:
tag:
description: "Existing protected CLI Preview tag, e.g. v1.19.0-preview.3"
required: true
type: string
recovery:
description: "Recover an existing partially published Preview from main-v2 history"
required: false
default: false
type: boolean
concurrency:
# Every Preview version advances the same public CLI, Desktop, and npm
# channels. Keep the whole all-surface event serialized across versions.
group: preview-release
cancel-in-progress: false
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate Preview release event
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
preview_number: ${{ steps.release.outputs.preview_number }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve Preview release
id: release
env:
RELEASE_TAG: ${{ inputs.tag }}
ALLOW_PREVIEW_RECOVERY: ${{ inputs.recovery }}
run: bash scripts/resolve-preview-release.sh
- name: Validate exact reviewed Preview notes
env:
VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
node scripts/release-event.mjs generate \
--version "$VERSION" \
--sha "$RELEASE_SHA" \
--published-at 2000-01-01T00:00:00.000Z \
--output /tmp/release-event.json
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
authorize:
name: approve Preview release
needs: preflight
runs-on: ubuntu-latest
environment: canary
outputs:
version: ${{ steps.approved.outputs.version }}
base_version: ${{ steps.approved.outputs.base_version }}
preview_number: ${{ steps.approved.outputs.preview_number }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved Preview event
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
BASE_VERSION: ${{ needs.preflight.outputs.base_version }}
PREVIEW_NUMBER: ${{ needs.preflight.outputs.preview_number }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "base_version=$BASE_VERSION"
echo "preview_number=$PREVIEW_NUMBER"
echo "cli_tag=$CLI_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved Preview release $VERSION at $RELEASE_SHA"
signpath-preflight:
name: verify Preview SignPath control plane
needs: authorize
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight: true
secrets: inherit
cli:
name: publish CLI Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release.yml
with:
channel: preview
tag: ${{ needs.authorize.outputs.cli_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
allow_preview_recovery: ${{ inputs.recovery }}
secrets: inherit
npm:
name: publish npm Canary
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-npm.yml
with:
channel: canary
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
secrets: inherit
desktop:
name: publish Desktop Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight_verified: true
secrets: inherit
postflight:
name: publish Preview release record
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every Preview publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for surface in CLI NPM DESKTOP; do
result_var="${surface}_RESULT"
if [ "${!result_var}" != "success" ]; then
echo "::error::$surface Preview publisher result is ${!result_var}, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Upload immutable public release marker
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
set -euo pipefail
node scripts/release-event.mjs generate \
--version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
existing="$(mktemp -d)"
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
echo "::error::published release-event.json differs from the approved Preview event"
exit 1
}
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+18 -6
View File
@@ -4,9 +4,9 @@ run-name: Release stable ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
# tag to this workflow on protected main-v2. Preflight verifies that all three
# tags point to the current main-v2 commit, and one GitHub approval releases
# every channel. After approval, a zero-publication Windows preflight verifies
# both architectures and signing stages before any public channel starts.
# tags point to the reviewed Notes candidate on main-v2 history, and one GitHub
# approval releases every surface. After approval, a zero-publication Windows
# preflight verifies both architectures and signing stages before publication.
# Keeping the control-plane ref on main-v2 lets SignPath restrict
# production signing to that one protected origin instead of trusting wildcard
# tag-like branch names. Manual recovery uses the same fixed control plane while
@@ -54,6 +54,7 @@ jobs:
name: validate stable release set
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
@@ -68,11 +69,11 @@ jobs:
# A recovery dispatch uses the fixed workflow/scripts from protected
# main-v2. Publishers still check out the immutable approved tag SHA.
ref: ${{ github.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Resolve stable release
@@ -81,6 +82,17 @@ jobs:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- name: Revalidate normal release candidate and exact push CI
if: ${{ !inputs.allow_recovery }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_CI_WAIT_SECONDS: 60
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
@@ -220,7 +232,7 @@ jobs:
# Postflight belongs to the trusted control plane, not the old build
# candidate, which may predate this verifier.
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Verify public artifacts and npm latest
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test notifier
+23 -15
View File
@@ -1,22 +1,20 @@
name: Release
# Native CLI binary line. Stable and Preview releases are called by their
# approved all-surface orchestrators. Manual dispatch remains available only for
# Stable/RC recovery; standalone prereleases remain internal stable-channel
# checkpoints behind the `release` gate.
# Native CLI binary line. Official releases are called by the protected Stable
# orchestrator. Manual dispatch remains available only for official recovery;
# historical Preview inputs below are workflow-call compatibility, not a public
# publication entrypoint.
on:
workflow_dispatch:
inputs:
channel:
description: "Standalone CLI recovery channel"
required: true
# Public Preview recovery reruns release-preview.yml so every surface and
# its immutable release-event marker stay aligned.
default: stable
type: choice
options: [stable]
tag:
description: "Existing protected Stable/RC CLI tag (for example v1.18.0 or v1.18.0-rc.1)"
description: "Existing official CLI tag (for example v1.18.0)"
required: true
type: string
workflow_call:
@@ -44,12 +42,12 @@ on:
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
allow_preview_recovery:
description: "Allow an approved Preview tag from main-v2 history"
description: "Legacy compatibility for already-created Preview runs"
required: false
default: false
type: boolean
@@ -176,20 +174,30 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
- name: Verify embedded documentation identity
env:
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }}
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
run: |
if [ ! -f scripts/verify-embedded-docs.sh ]; then
echo "Legacy candidate predates the embedded docs contract; skipping."
exit 0
fi
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
goreleaser:
name: archives + checksums + homebrew tap
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Standalone prereleases pass release-gate above. This job therefore must not
# add a second GitHub environment approval.
# The Stable caller has already passed the single GitHub release approval.
# Official standalone recovery passes release-gate above. This job therefore
# must not add a second GitHub environment approval.
steps:
- uses: actions/checkout@v7
with:
@@ -217,11 +225,11 @@ jobs:
printf 'product checkout is dirty before release:\n%s\n' "$dirty" >&2
exit 1
fi
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Download orchestrator-reviewed release notes
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test sweeper
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Update README contributor tables
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test chart generator
+1 -1
View File
@@ -14,7 +14,7 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Tag }}
- -s -w -X main.version={{ .Tag }} -X reasonix/internal/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/productdocs.linkedRevision={{ .Commit }}
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
-4
View File
@@ -6,15 +6,11 @@ allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/release-stable.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-cli-trigger.yml
- .github/workflows/release-desktop.yml
- .github/workflows/release-desktop-trigger.yml
- .signpath/artifact-configurations/windows-payload.xml
- .signpath/artifact-configurations/windows-installer-v2.xml
- scripts/complete-signpath-request.ps1
+5
View File
@@ -69,6 +69,11 @@ branch.
### Fixed
- Fixed long parallel sub-agent research being silently lost when combined
`parallel_tasks` or `fleet` answers exceeded the single-tool output limit.
Persisted sessions now keep each child transcript independently, return a
bounded fair preview plus stable reference for every result, and page full
answers through the conversation-scoped `read_subagent_result` tool.
- Fixed Remote Workbench failing with only `initialize: workbench-desktop:
connection closed` on fresh or cross-platform SSH hosts. Desktop now proves
the exact Host CLI Build ID, provisions the matching verified release without
+6 -1
View File
@@ -30,6 +30,11 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/27020?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
</p>
<br/>
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
@@ -141,7 +146,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
+5
View File
@@ -30,6 +30,11 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/27020?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
+18 -10
View File
@@ -64,6 +64,8 @@ type result struct {
Note string
}
const defaultSuiteTokenBudget = 800_000
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
@@ -85,7 +87,7 @@ func main() {
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
@@ -224,15 +226,7 @@ func runTask(bin, model, profile string, t task) result {
defer cancel()
metricsPath := filepath.Join(work, ".run-metrics.json")
args := []string{"run", "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if t.MaxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
args = append(args, t.Prompt)
args := buildRunTaskArgs(metricsPath, model, profile, t.MaxSteps, t.Prompt)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = work
@@ -253,6 +247,20 @@ func runTask(bin, model, profile string, t task) result {
return r
}
func buildRunTaskArgs(metricsPath, model, profile string, maxSteps int, prompt string) []string {
// Benchmarks are unattended and their fixtures require ordinary workspace
// writes. Auto still honors explicit ask/deny rules and the sandbox boundary.
args := []string{"run", "--auto", "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if maxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
return append(args, prompt)
}
func grade(work, taskDir string) bool {
verify := filepath.Join(taskDir, "verify.sh")
if !fileExists(verify) {
+21
View File
@@ -21,6 +21,27 @@ func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T)
}
}
func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
got := buildRunTaskArgs("metrics.json", "e2e", benchmarkProfileDelivery, 12, "fix it")
want := []string{
"run", "--auto", "--metrics", "metrics.json",
"--model", "e2e", "--max-steps", "12",
"--profile", "delivery", "fix it",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("run task args = %v, want %v", got, want)
}
}
func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
// The real-provider baseline exceeded 400k after only three successful
// tasks. Keep enough headroom to grade all five instead of silently skipping
// the final scenarios as normal model and cache usage varies.
if defaultSuiteTokenBudget < 800_000 {
t.Fatalf("default suite token budget = %d, want at least 800000", defaultSuiteTokenBudget)
}
}
func TestNormalizeBenchmarkProfile(t *testing.T) {
for _, input := range []string{"", "baseline", " BASELINE "} {
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
-381
View File
@@ -1,381 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
"reasonix/internal/repair"
)
func runAssist(args []string) int {
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
root := fs.String("root", ".", "project root to diagnose")
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
network := fs.Bool("network", true, "include provider connectivity diagnostics")
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := requestRepairPlan(ctx, *root, *model, report)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
PlanID string `json:"planId"`
Preview []repair.RepairPlanPreview `json:"preview"`
PreviewID string `json:"previewId"`
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
return code
}
} else {
printPlanPreview(plan, preview)
}
if !*apply {
return 0
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func runApplyPlan(args []string) int {
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
root := fs.String("root", ".", "project root")
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
}
b, err := os.ReadFile(*file)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := repair.DecodeRepairPlan(b)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if !*jsonOut {
printPlanPreview(plan, preview)
}
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
expectedPreviewID := strings.TrimSpace(*previewID)
if *yes && expectedPreviewID == "" {
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
return 2
}
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
return 1
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = actualPreviewID
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
cfg, err := loadAIProviderConfig(root)
if err != nil {
return repair.RepairPlan{}, err
}
if strings.TrimSpace(modelRef) == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
}
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
if err != nil {
return repair.RepairPlan{}, err
}
safeReport, snapshotAliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
return repair.RepairPlan{}, err
}
stream, err := p.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
{Role: provider.RoleUser, Content: string(payload)},
},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
return repair.RepairPlan{}, err
}
var out strings.Builder
for chunk := range stream {
switch chunk.Type {
case provider.ChunkText:
out.WriteString(chunk.Text)
case provider.ChunkError:
if chunk.Err != nil {
return repair.RepairPlan{}, chunk.Err
}
}
}
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
if err != nil {
return repair.RepairPlan{}, err
}
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
},
}
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
return cfg, nil
}
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
for _, snapshot := range snapshots {
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
if loadErr == nil {
return cfg, nil
}
}
}
// Built-in recovery defaults resolve the global credential file without
// reading, migrating, or rewriting malformed user/project TOML.
return config.LoadRecoveryDefaultsForRoot(root), nil
}
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
Allowed actions only (return an empty actions array when no safe action applies):
- repair_config with scope global or project
- restore_snapshot with snapshotId
- rebuild_derived_state with target tabs, projects, window, zoom, or all
- rollback_update with no parameters
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
// It deliberately carries no free-form diagnostic strings or state metadata:
// finding messages can quote config lines, URLs, commands, and permission
// rules, while snapshot and update metadata can be edited on disk. Only fixed
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
// machine.
type providerSafeReport struct {
Root string `json:"root"`
Network bool `json:"network"`
Snapshots []providerSafeSnapshot `json:"snapshots"`
PendingUpdate bool `json:"pendingUpdate"`
Findings []providerSafeFinding `json:"findings"`
}
type providerSafeSnapshot struct {
ID string `json:"id"`
}
type providerSafeFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Scope string `json:"scope,omitempty"`
}
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
safe := providerSafeReport{
Root: "<project>",
Network: report.Network,
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
PendingUpdate: report.PendingUpdate != nil,
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
}
snapshotAliases := make(map[string]string, len(report.Snapshots))
for _, snapshot := range report.Snapshots {
if strings.TrimSpace(snapshot.ID) != "" {
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
snapshotAliases[alias] = snapshot.ID
}
}
for _, finding := range report.Findings {
safe.Findings = append(safe.Findings, providerSafeFinding{
Severity: providerSafeSeverity(finding.Severity),
Code: providerSafeDiagnosticCode(finding.Code),
Scope: providerSafeScope(finding.Scope),
})
}
return safe, snapshotAliases
}
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
resolved := plan
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
for i := range resolved.Actions {
action := &resolved.Actions[i]
if action.Type != "restore_snapshot" {
continue
}
id, ok := aliases[action.SnapshotID]
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
}
action.SnapshotID = id
}
return resolved, nil
}
func providerSafeSeverity(severity string) string {
switch severity {
case "error", "warning", "info":
return severity
default:
return "unknown"
}
}
func providerSafeDiagnosticCode(code string) string {
switch code {
case "config.invalid_toml", "config.load_failed",
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
"provider.invalid_key_name", "provider.missing_key",
"model.no_default", "model.invalid_default",
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
"permissions.conflict", "file.permissions",
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
"derived.invalid_json",
"network.invalid_proxy", "network.client_failed", "network.unreachable",
"network.authentication_failed", "network.ok", "network.unexpected_status":
return code
default:
return "unknown"
}
}
// providerSafeScope generalizes a finding scope to a closed vocabulary.
// derived:* names survive because rebuild_derived_state needs the target and
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
// and collapse to their category; anything unrecognized becomes "other".
func providerSafeScope(scope string) string {
scope = strings.TrimSpace(scope)
switch {
case scope == "":
return ""
case strings.HasPrefix(scope, "derived:"):
switch scope[len("derived:"):] {
case "tabs", "projects", "window", "zoom":
return scope
}
return "derived:other"
case strings.HasPrefix(scope, "provider:"):
return "provider"
case strings.HasPrefix(scope, "plugin:"):
return "plugin"
}
switch scope {
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
return scope
}
return "other"
}
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
fmt.Println("AI repair plan:", plan.Summary)
for _, preview := range previews {
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
if preview.Diff != "" {
fmt.Println(preview.Diff)
}
}
}
func confirmPlan() bool {
fmt.Print("Apply this repair plan? [y/N] ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
-246
View File
@@ -1,246 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
entry := &config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://opencode.ai/zen/go/v1",
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
}
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
if effort := got.Extra["effort"]; effort != "max" {
t.Fatalf("effort = %#v, want max", effort)
}
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
}
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
}
}
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
// quote config lines, MCP command lines, permission rules, URLs with
// credentials, emails, tokens, or absolute paths.
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
const metadataSecret = "snapshot-metadata-secret"
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
secrets := []string{
"/Users/someone/dotfiles",
"[email protected]",
"sk-live-Abc123Secret",
"npx --api-key=sk-live-Abc123Secret mcp-server",
"bash(rm -rf *)",
"corp-internal-llm",
"https://user:[email protected]:8080",
metadataSecret,
"secret.finding.code",
"secret-severity",
snapshotID,
}
report := repair.DiagnosticReport{
GeneratedAt: metadataSecret,
Root: "/Users/someone/dotfiles",
Network: true,
Snapshots: []repair.DiagnosticSnapshot{
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
},
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
Findings: []repair.DiagnosticFinding{
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
Message: "The configured API key is missing."},
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
Message: "Derived desktop state desktop-tabs.json is malformed."},
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
Message: metadataSecret, Remediation: metadataSecret},
},
}
safeReport, aliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
t.Fatal(err)
}
body := string(payload)
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
}
}
for _, want := range []string{
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
`"code":"config.invalid_toml"`,
`"scope":"provider"`,
`"scope":"plugin"`,
`"scope":"derived:tabs"`,
`"id":"snapshot-1"`,
`"id":"snapshot-2"`,
`"pendingUpdate":true`,
`"severity":"unknown"`,
`"code":"unknown"`,
`"scope":"other"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("outbound payload missing %s:\n%s", want, body)
}
}
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
t.Fatalf("snapshot alias map = %v", aliases)
}
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
if strings.Contains(body, `"`+forbiddenField+`"`) {
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
}
}
}
func TestResolveProviderSnapshotAliases(t *testing.T) {
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
plan, err := repair.DecodeRepairPlan([]byte(`{
"schemaVersion": 1,
"summary": "restore newest snapshot",
"actions": [
{"type":"repair_config","scope":"global","reason":"invalid config"},
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
]
}`))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
if err != nil {
t.Fatal(err)
}
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
}
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
t.Fatalf("alias resolution mutated source plan: %q", got)
}
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
t.Fatal("unknown snapshot alias was accepted")
}
}
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
t.Helper()
file := filepath.Join(t.TempDir(), "plan.json")
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
plan, err := repair.DecodeRepairPlan(raw)
if err != nil {
t.Fatal(err)
}
return file, plan
}
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, _ := writeDerivedStatePlan(t)
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
t.Fatal(err)
}
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
t.Fatalf("exit code = %d, want stale preview refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
t.Fatalf("exit code = %d, want confirmed preview application", got)
}
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
"global": "global",
"project": "project",
"derived:tabs": "derived:tabs",
"derived:zoom": "derived:zoom",
"derived:/etc/passwd": "derived:other",
"provider:corp-llm": "provider",
"plugin:internal-mcp": "plugin",
"credential file": "credential file",
"[email protected]": "other",
"/Users/someone/project": "other",
}
for in, want := range cases {
if got := providerSafeScope(in); got != want {
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
}
}
}
-415
View File
@@ -1,415 +0,0 @@
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
// desktop shell. It is packaged beside the desktop application so recovery
// remains available when Wails, WebView, or user configuration cannot start.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/repair"
)
var version = "dev"
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
return runLaunch(nil)
}
switch args[0] {
case "check":
return runCheck(args[1:], false)
case "repair":
return runCheck(args[1:], true)
case "launch":
return runLaunch(args[1:])
case "recover":
return runRecover(args[1:])
case "snapshots":
return runSnapshots(args[1:])
case "restore":
return runRestore(args[1:])
case "undo":
return runUndo(args[1:])
case "diagnose":
return runDiagnose(args[1:])
case "rebuild":
return runRebuild(args[1:])
case "assist":
return runAssist(args[1:])
case "apply-plan":
return runApplyPlan(args[1:])
case "version", "--version", "-v":
fmt.Println("reasonix-guard", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
if isMacBundleLauncher() {
return runLaunch(append([]string{"--"}, args...))
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
usage()
return 2
}
}
func runDiagnose(args []string) int {
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
network := fs.Bool("network", false, "probe provider endpoints and credentials")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(report); code != 0 {
return code
}
} else {
fmt.Println("Reasonix Guard diagnostics")
if len(report.Findings) == 0 {
fmt.Println(" ok: no issues found")
}
for _, finding := range report.Findings {
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
}
}
if report.HasErrors() {
return 1
}
return 0
}
func runRebuild(args []string) int {
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
target := fs.String("target", "", "tabs|projects|window|zoom|all")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
return 2
}
applied, err := repair.RebuildDerivedState(*target)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(applied)
}
for _, path := range applied {
fmt.Println("quarantined:", path)
}
return 0
}
func runSnapshots(args []string) int {
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
snapshots, err := repair.ListConfigSnapshots()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(snapshots)
}
for _, snap := range snapshots {
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
}
return 0
}
func runRestore(args []string) int {
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
snapshot := fs.String("snapshot", "", "snapshot id to restore")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
return 2
}
tx, err := repair.RestoreConfigSnapshot(*snapshot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
return 0
}
func runUndo(args []string) int {
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
tx, err := repair.UndoLastRepair()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(tx)
}
fmt.Println("undid repair:", tx.ID)
return 0
}
func printJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func runCheck(args []string, apply bool) int {
name := "reasonix-guard check"
if apply {
name = "reasonix-guard repair"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return 1
}
} else {
fmt.Println("Reasonix Guard")
for _, check := range report.Checks {
status := "ok"
if !check.Exists {
status = "missing"
} else if !check.Valid {
status = "invalid"
}
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
if check.Error != "" {
fmt.Println(" ", check.Error)
}
}
for _, action := range report.Applied {
fmt.Println(" applied:", action)
}
}
for _, check := range report.Checks {
if check.Exists && !check.Valid {
return 1
}
}
return 0
}
// failedInstallBlocksLaunch decides whether startup must stop after a failed
// RecoverFailedInstall. The failure marker means the installer may have left
// the release unit partially replaced, so the binaries cannot be presumed
// coherent until a rollback completes: any error with the restore unfinished
// fails closed. MixedInstall alone is not the signal — a rollback that failed
// before touching anything (e.g. while staging) reports MixedInstall=false yet
// leaves the installer's half-written unit in place. Only a completed restore
// (RolledBack, with just the marker cleanup failing) is safe to launch.
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
return err != nil && !result.RolledBack
}
func runLaunch(args []string) int {
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
app := fs.String("app", "", "desktop executable path")
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
if err := fs.Parse(args); err != nil {
return 2
}
// An update helper that watched the installer fail records a marker (it
// cannot roll back itself from outside the install directory). Restore the
// previous release unit immediately instead of waiting for a crash loop.
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
if failedInstallBlocksLaunch(result, err) {
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
forceSafeMode := false
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
return 1
}
forceSafeMode = true
}
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
} else if result.Cleared {
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode || forceSafeMode
if !useSafeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
// The compensated install is still a coherent (new-version) release
// unit, so a Safe Mode boot is a sound fallback while the pending
// transaction stays on disk for the next rollback attempt.
useSafeMode = true
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
_ = tracker.MarkClean()
} else {
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
}
useSafeMode = true
case recoverySafeMode:
useSafeMode = true
case recoveryQuit:
return 0
}
}
}
path := strings.TrimSpace(*app)
if path == "" {
path = siblingDesktopExecutable()
}
if path == "" {
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
return 1
}
childArgs := append([]string(nil), fs.Args()...)
if useSafeMode {
childArgs = append([]string{"--safe-mode"}, childArgs...)
}
cmd := exec.Command(path, childArgs...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if *detach {
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
if err := cmd.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok {
return exit.ExitCode()
}
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func packagedDetachedLauncher() bool {
if runtime.GOOS != "windows" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
name := strings.ToLower(filepath.Base(exe))
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
}
func runRecover(args []string) int {
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
app := fs.String("app", "", "desktop executable path")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
return 1
}
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
case recoverySafeMode:
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
default:
return 0
}
}
func siblingDesktopExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
name := "reasonix-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
if runtime.GOOS == "darwin" {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func isMacBundleLauncher() bool {
if runtime.GOOS != "darwin" {
return false
}
exe, err := os.Executable()
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
}
func usage() {
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/repair"
)
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestFailedInstallBlocksLaunch(t *testing.T) {
cases := []struct {
name string
result repair.UpdateRollbackResult
err error
want bool
}{
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
}
for _, tc := range cases {
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
}
}
}
-9
View File
@@ -1,9 +0,0 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
-24
View File
@@ -1,24 +0,0 @@
//go:build darwin
package main
import (
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return recoveryRepair
case "Safe Mode":
return recoverySafeMode
default:
return recoveryQuit
}
}
-36
View File
@@ -1,36 +0,0 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
if path, err := exec.LookPath("zenity"); err == nil {
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
"--text=Reasonix failed to start repeatedly.",
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
out, err := cmd.Output()
if strings.TrimSpace(string(out)) == "Quit" {
return recoveryQuit
}
if err == nil {
return recoveryRepair
}
return recoverySafeMode
}
if path, err := exec.LookPath("kdialog"); err == nil {
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
if err == nil {
return recoveryRepair
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return recoveryQuit
}
}
return recoverySafeMode
}
-5
View File
@@ -1,5 +0,0 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
-27
View File
@@ -1,27 +0,0 @@
//go:build windows
package main
import (
"os/exec"
"strings"
"syscall"
)
func nativeRecoveryChoice() recoveryChoice {
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Yes":
return recoveryRepair
case "No":
return recoverySafeMode
default:
return recoveryQuit
}
}
+32
View File
@@ -0,0 +1,32 @@
// Command reasonix-launcher is the permanent thin desktop entry point for the
// versioned install layout (v1.20+). It reads InstallRoot/current.json, starts
// the active reasonix-desktop binary, and exits. It never counts crashes,
// chooses previous versions, or enters a product safe mode.
//
// Migration compatibility: old shortcuts may still pass "launch", "--detach",
// or "--safe-mode". Those tokens are stripped and ignored.
package main
import (
"os"
"reasonix/internal/desktoplauncher"
)
var version = "dev"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
return desktoplauncher.Run(args, version)
}
func resolveDesktopPath(installRoot string) (string, error) {
return desktoplauncher.ResolveDesktopPath(installRoot)
}
func stripLegacyLaunchArgs(args []string) []string {
return desktoplauncher.StripLegacyLaunchArgs(args)
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
)
func TestStripLegacyLaunchArgs(t *testing.T) {
got := stripLegacyLaunchArgs([]string{
"launch", "--detach", "--safe-mode", "--app", `C:\Old\reasonix-desktop.exe`, "--foo", "bar",
})
want := []string{"--foo", "bar"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %#v want %#v", got, want)
}
got = stripLegacyLaunchArgs([]string{"--app=ignored", "keep"})
if len(got) != 1 || got[0] != "keep" {
t.Fatalf("got %#v", got)
}
got = stripLegacyLaunchArgs([]string{"--", "--safe-mode", "x"})
if len(got) != 3 || got[0] != "--" {
t.Fatalf("separator not preserved: %#v", got)
}
}
func TestResolveDesktopPathUsesCurrentJSON(t *testing.T) {
root := t.TempDir()
src := t.TempDir()
version := "v1.20.0"
members := make([]installlayout.Member, 0, 3)
for _, name := range installlayout.AllowedVersionMembers() {
path := filepath.Join(src, name)
if err := os.WriteFile(path, []byte("bin-"+name), 0o755); err != nil {
t.Fatal(err)
}
members = append(members, installlayout.Member{Name: name, Path: path})
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: version,
RequestID: "launcher-test",
Members: members,
}); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "versions", version, installlayout.DesktopBinaryName())
if path != want {
t.Fatalf("path=%s want=%s", path, want)
}
}
func TestResolveDesktopPathFallsBackToFlatSibling(t *testing.T) {
root := t.TempDir()
flat := filepath.Join(root, installlayout.DesktopBinaryName())
if err := os.WriteFile(flat, []byte("flat"), 0o755); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if path != flat {
t.Fatalf("path=%s want=%s", path, flat)
}
}
func TestResolveDesktopPathRejectsCorruptPointer(t *testing.T) {
root := t.TempDir()
// Write a current.json that fails schema validation.
if err := os.WriteFile(filepath.Join(root, installlayout.CurrentFileName), []byte(`{"schemaVersion":99}`), 0o644); err != nil {
t.Fatal(err)
}
// Even with a flat sibling, a corrupt pointer must fail closed. Falling back
// would silently run a stale executable after a partially observed update.
if err := os.WriteFile(filepath.Join(root, installlayout.DesktopBinaryName()), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if path, err := resolveDesktopPath(root); err == nil {
t.Fatalf("corrupt current.json resolved stale flat desktop %q", path)
}
}
+525
View File
@@ -0,0 +1,525 @@
// Command reasonix-legacy-migrator is the one-shot flat→versioned install
// migrator used by v1.20+ packaging. In compatibility payloads it is still
// named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
// and behavior are intentionally separate from the old Guard recovery product.
//
// It only: acquires a migration lock, validates the flat release unit, creates
// versions/<version>, writes current.json, rewrites entry points, starts the
// thin launcher, and self-deletes when possible. It never chooses safe mode,
// counts crashes, or auto-rolls back.
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"reasonix/internal/desktoplauncher"
"reasonix/internal/fileutil"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
var version = "dev"
const migrationLockName = ".reasonix-layout-migrate.lock"
func main() {
if runningAsLauncher() {
os.Exit(desktoplauncher.Run(os.Args[1:], version))
}
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 1 {
switch args[0] {
case "version", "--version", "-v":
fmt.Println("reasonix-legacy-migrator", version)
return 0
case "help", "--help", "-h":
fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
return 0
}
}
installRoot := ""
activeVersion := strings.TrimSpace(version)
activateStaging := ""
relaunch := true
for i := 0; i < len(args); i++ {
switch args[i] {
case "--install-root":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
return 2
}
i++
installRoot = args[i]
case "--version":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --version requires a value")
return 2
}
i++
activeVersion = args[i]
case "--activate-staging":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
return 2
}
i++
activateStaging = args[i]
case "launch", "--detach", "--safe-mode":
// Accept legacy argv from old shortcuts; no product behavior.
continue
case "--no-relaunch":
relaunch = false
continue
case "--app":
if i+1 < len(args) {
i++
}
continue
}
}
if installRoot == "" {
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
installRoot = filepath.Dir(exe)
}
installRoot = filepath.Clean(installRoot)
if activateStaging != "" {
activeVersion, err := normalizeActiveVersion(activeVersion)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if relaunch {
_ = startLauncher(installRoot)
}
return 0
}
if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func migrate(installRoot, activeVersion string) error {
return migrateWithRelaunch(installRoot, activeVersion, true)
}
func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
var err error
activeVersion, err = normalizeActiveVersion(activeVersion)
if err != nil {
return err
}
unlock, err := acquireMigrationLock(installRoot)
if err != nil {
return err
}
defer unlock()
// Pointer already committed: only cleanup, never overwrite the active version.
// A present but corrupt pointer is fatal; treating it like an absent pointer
// could overwrite evidence of a partial activation and migrate stale files.
currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
if _, statErr := os.Lstat(currentPath); statErr == nil {
ptr, err := installlayout.ReadCurrent(installRoot)
if err != nil {
return err
}
if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
return err
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
_ = startLauncher(installRoot) // best-effort; layout already committed
selfDelete()
}
return nil
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("migrate: inspect current.json: %w", statErr)
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
helperName := installlayout.UpdateHelperBinaryName()
flatDesktop := filepath.Join(installRoot, desktopName)
if _, err := os.Lstat(flatDesktop); err != nil {
return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
}
members := []installlayout.Member{
{Name: desktopName, Path: flatDesktop},
}
if p := optionalRegular(filepath.Join(installRoot, cliName)); p != "" {
members = append(members, installlayout.Member{Name: cliName, Path: p})
} else {
// CLI may be absent on some portable trees; synthesize from desktop only
// is not allowed — require the whitelist. Prefer copying desktop as a
// last-resort placeholder is forbidden; fail closed.
return fmt.Errorf("migrate: flat CLI binary %s is required", cliName)
}
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, helperName)
if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
members = append(members, installlayout.Member{Name: helperName, Path: p})
} else {
return fmt.Errorf("migrate: flat update helper %s is required", helperName)
}
}
// Old Linux updaters only publish desktop, CLI, and the compatibility
// migrator. On Unix the migrator is also a thin-launcher multicall binary,
// so it can create the permanent entry point without another payload member.
if err := ensureLauncherEntry(installRoot); err != nil {
return err
}
// v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
// The flat release unit is still intact here, so commit that exact verified
// transaction before moving its files into the version directory.
if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
return err
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "legacy-migrate",
Members: members,
RequiredNames: requiredNames,
}); err != nil {
return fmt.Errorf("migrate: activate versioned layout: %w", err)
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
// Launcher start is best-effort: layout activation is the commit point.
if err := startLauncher(installRoot); err != nil {
fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
}
// Unix can unlink the running migrator. On Windows the parent thin launcher
// removes it after this process exits.
selfDelete()
}
return nil
}
func normalizeActiveVersion(activeVersion string) (string, error) {
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
// Accept bare "dev" builds in development only.
if activeVersion == "" || activeVersion == "dev" {
activeVersion = "v0.0.0-dev"
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else if !strings.HasPrefix(activeVersion, "v") {
activeVersion = "v" + activeVersion
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else {
return "", err
}
}
return activeVersion, nil
}
func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
return fmt.Errorf("installer staging must be inside the install root")
}
info, err := os.Lstat(stagingRoot)
if err != nil {
return fmt.Errorf("inspect installer staging: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("installer staging must be a real directory")
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
}
members := make([]installlayout.Member, 0, len(requiredNames))
for _, name := range requiredNames {
members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
}
launcherName := installlayout.LauncherBinaryName()
launcherSource := filepath.Join(stagingRoot, launcherName)
rootMembers := []installlayout.Member{
{Name: launcherName, Path: launcherSource},
{Name: cliName, Path: filepath.Join(stagingRoot, cliName)},
}
requiredRootNames := []string{launcherName, cliName}
if alias := installlayout.PortableAliasName(); alias != "" {
rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
requiredRootNames = append(requiredRootNames, alias)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "signed-installer-" + activeVersion,
Members: members,
RequiredNames: requiredNames,
RootMembers: rootMembers,
RequiredRootNames: requiredRootNames,
}); err != nil {
return fmt.Errorf("activate signed installer staging: %w", err)
}
return nil
}
func runningAsLauncher() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
}
func optionalRegular(path string) string {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ""
}
return path
}
func acquireMigrationLock(installRoot string) (func(), error) {
if err := os.MkdirAll(installRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(installRoot, migrationLockName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if os.IsExist(err) {
// Stale lock from a dead migrator: if older than 10 minutes, steal it.
info, statErr := os.Stat(path)
if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
_ = os.Remove(path)
f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
}
}
if err != nil {
return nil, fmt.Errorf("migrate: acquire lock: %w", err)
}
}
_, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
_ = f.Close()
return func() { _ = os.Remove(path) }, nil
}
func ensureLauncherEntry(installRoot string) error {
launcher := installlayout.LauncherBinaryName()
path := filepath.Join(installRoot, launcher)
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
}
return nil
}
// On Windows also accept Reasonix.exe as the alias.
if runtime.GOOS == "windows" {
if _, err := os.Lstat(filepath.Join(installRoot, "Reasonix.exe")); err == nil {
return nil
}
return fmt.Errorf("migrate: thin launcher %s is missing; install a signed package", launcher)
}
// Legacy Linux archives cannot deliver a fourth member through the old
// updater. Copy this signed multicall migrator as the permanent thin launcher.
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("migrate: resolve migrator executable: %w", err)
}
info, err := os.Lstat(exe)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: migrator executable is not a regular file")
}
body, err := os.ReadFile(exe)
if err != nil {
return fmt.Errorf("migrate: read launcher source: %w", err)
}
if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
return fmt.Errorf("migrate: install thin launcher: %w", err)
}
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("migrate: read legacy pending update: %w", err)
}
if tx.TargetKind != "file" {
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
}
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
}
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
return fmt.Errorf("migrate: pending update target escapes install root")
}
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
}
if current, err := repair.ReadPendingUpdate(); err == nil {
if repair.UpdateTransactionID(current) == id {
return fmt.Errorf("migrate: legacy pending update remained after commit")
}
return fmt.Errorf("migrate: pending update changed during commit")
} else if !os.IsNotExist(err) {
return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
}
return nil
}
func pathWithinInstallRoot(installRoot, target string) bool {
root := filepath.Clean(strings.TrimSpace(installRoot))
target = filepath.Clean(strings.TrimSpace(target))
if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
return false
}
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return false
}
return true
}
func cleanupLegacyFlatFiles(installRoot string) error {
// Remove flat desktop/CLI/helper/guard sidecars after the version tree is
// active. Never delete the thin launcher or current.json.
names := []string{
installlayout.DesktopBinaryName(),
installlayout.CLIBinaryName(),
installlayout.UpdateHelperBinaryName(),
}
if runtime.GOOS == "windows" {
names = append(names, "reasonix-guard.exe")
} else {
names = append(names, "reasonix-guard")
}
for _, name := range names {
path := filepath.Join(installRoot, name)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
_ = os.Remove(path)
}
return nil
}
func archiveInstallRootLegacyMarkers(installRoot string) error {
// Very old portable builds wrote markers beside the binary. Current repair
// state under Reasonix home is finalized through repair transaction APIs.
markers := []string{
"pending-update.json",
"startup-state.json",
}
ts := time.Now().UTC().Format("20060102T150405Z")
destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
moved := false
for _, name := range markers {
src := filepath.Join(installRoot, name)
if _, err := os.Lstat(src); err != nil {
continue
}
if err := os.MkdirAll(destRoot, 0o755); err != nil {
return err
}
dest := filepath.Join(destRoot, name)
if err := os.Rename(src, dest); err != nil {
// Cross-device: copy+remove best effort.
data, readErr := os.ReadFile(src)
if readErr != nil {
continue
}
if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
_ = os.Remove(src)
}
}
moved = true
}
if moved {
meta, _ := json.MarshalIndent(map[string]any{
"migratedAt": time.Now().UTC().Format(time.RFC3339),
"from": "flat-v1",
"to": installlayout.InstallLayoutVersionedV1,
}, "", " ")
_ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
}
return nil
}
func startLauncher(installRoot string) error {
launcher := "reasonix-launcher"
if runtime.GOOS == "windows" {
launcher += ".exe"
}
path := filepath.Join(installRoot, launcher)
if _, err := os.Lstat(path); err != nil {
if runtime.GOOS == "windows" {
path = filepath.Join(installRoot, "Reasonix.exe")
}
}
if _, err := os.Lstat(path); err != nil {
// Migration succeeded; user can start manually.
return nil
}
cmd := exec.Command(path)
cmd.Dir = installRoot
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if runtime.GOOS == "windows" {
return cmd.Start()
}
return cmd.Start()
}
func selfDelete() {
exe, err := os.Executable()
if err != nil {
return
}
// Do not delete if we are not named like a compatibility guard payload.
base := strings.ToLower(filepath.Base(exe))
if base != "reasonix-guard" && base != "reasonix-guard.exe" {
return
}
_ = os.Remove(exe)
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/installlayout"
)
func installerVersionNames() []string {
names := []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()}
if runtime.GOOS == "windows" {
names = append(names, installlayout.UpdateHelperBinaryName())
}
return names
}
func writeInstallerStaging(t *testing.T, root, label string, includeLauncher bool) string {
t.Helper()
staging := filepath.Join(root, "versions", ".installer-"+label)
if err := os.MkdirAll(staging, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range installerVersionNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if includeLauncher {
name := installlayout.LauncherBinaryName()
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
return staging
}
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
// Flat release unit.
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("flat-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
// Thin launcher entry must already exist (packaging places it). Use a
// non-executable marker so startLauncher fails closed without hanging.
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher.exe"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "Reasonix.exe"), []byte("launcher"), 0o644)
// Marker files that must be archived, not deleted silently without record.
_ = os.WriteFile(filepath.Join(root, "pending-update.json"), []byte(`{"pending":true}`), 0o644)
_ = os.WriteFile(filepath.Join(root, "startup-state.json"), []byte(`{}`), 0o644)
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(root) {
t.Fatal("current.json missing after migration")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
// Flat desktop must be cleaned up after successful activation.
if _, err := os.Stat(filepath.Join(root, installlayout.DesktopBinaryName())); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed after migration")
}
// Active desktop lives under versions/.
if _, err := installlayout.ActiveDesktopPath(root); err != nil {
t.Fatal(err)
}
// Second run is idempotent cleanup-only.
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatalf("idempotent re-run: %v", err)
}
ptr2, err := installlayout.ReadCurrent(root)
if err != nil || ptr2.ActiveVersion != "v1.20.0" {
t.Fatalf("re-run overwrote active version: %+v", ptr2)
}
}
func TestMigrateRefusesWithoutFlatUnit(t *testing.T) {
root := t.TempDir()
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("expected failure without flat desktop")
}
}
func TestMigrateRefusesCorruptCurrentPointerWithoutOverwritingIt(t *testing.T) {
root := t.TempDir()
corrupt := []byte(`{"schemaVersion":99}`)
current := filepath.Join(root, installlayout.CurrentFileName)
if err := os.WriteFile(current, corrupt, 0o644); err != nil {
t.Fatal(err)
}
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("stale-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("corrupt current.json was treated as an absent pointer")
}
got, err := os.ReadFile(current)
if err != nil {
t.Fatal(err)
}
if string(got) != string(corrupt) {
t.Fatalf("corrupt pointer was overwritten: %q", got)
}
}
func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
root := t.TempDir()
staging := writeInstallerStaging(t, root, "new", true)
if err := activateInstallerStaging(root, "v1.20.0", staging); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(activeDesktop); err != nil || string(got) != "new-"+installlayout.DesktopBinaryName() {
t.Fatalf("active desktop=%q err=%v", got, err)
}
for _, name := range []string{installlayout.LauncherBinaryName(), installlayout.CLIBinaryName()} {
if info, err := os.Lstat(filepath.Join(root, name)); err != nil || !info.Mode().IsRegular() {
t.Fatalf("root entry %s missing or invalid: %v", name, err)
}
}
if alias := installlayout.PortableAliasName(); alias != "" {
if got, err := os.ReadFile(filepath.Join(root, alias)); err != nil || string(got) != "new-"+installlayout.LauncherBinaryName() {
t.Fatalf("portable alias=%q err=%v", got, err)
}
}
}
func TestActivateInstallerStagingFailureKeepsPreviousPointer(t *testing.T) {
root := t.TempDir()
oldStaging := writeInstallerStaging(t, root, "old", true)
if err := activateInstallerStaging(root, "v1.19.1", oldStaging); err != nil {
t.Fatal(err)
}
broken := writeInstallerStaging(t, root, "broken", false)
if err := activateInstallerStaging(root, "v1.20.0", broken); err == nil {
t.Fatal("staging without a launcher was activated")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.19.1" {
t.Fatalf("previous pointer changed after failed activation: %+v err=%v", ptr, err)
}
if err := activateInstallerStaging(root, "v1.20.0", t.TempDir()); err == nil {
t.Fatal("staging outside the install root was accepted")
}
}
+5 -2
View File
@@ -29,8 +29,11 @@ func TestRunWithCrashCaptureRecordsAndReraises(t *testing.T) {
if err != nil || len(reports) != 1 {
t.Fatalf("captured reports=%d err=%v", len(reports), err)
}
if got := reports[0].Report.TopFrame; !strings.Contains(got, "main_test.go:") {
t.Fatalf("top frame = %q, want panic call site in main_test.go", got)
// Token-like function and file basenames may be redacted by the current
// privacy filter. Keep asserting that the top frame is the test call site
// without requiring its pre-redaction basename.
if got := reports[0].Report.TopFrame; !strings.Contains(got, "_test.go:") {
t.Fatalf("top frame = %q, want sanitized panic call site in a test file", got)
}
preview, err := crashreport.Preview(reports[0].Report)
if err != nil {
-1
View File
@@ -28,7 +28,6 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
}
want := []string{
".github/workflows/release-desktop.yml",
".github/workflows/release-preview.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
+1
View File
@@ -30,6 +30,7 @@
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/io.reasonix.desktop.update.policy
!/build/linux/postinstall.sh
!/build/linux/icons
!/build/linux/icons/**
+13 -12
View File
@@ -154,22 +154,23 @@ not depend on homepage badge semantics. Self-update behavior by platform:
in-place swap would be blocked by Gatekeeper; the banner links to the download
page for a manual update instead.
### Unsigned builds — first launch
### Code signing — first launch
There are no Apple/Windows code-signing certificates yet, so a downloaded build
trips the OS gatekeepers on first run:
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
Applications. Gatekeeper may then report the app "is damaged" or is from an
unidentified developer; clear the quarantine attribute and open it:
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
per release; `release-desktop.yml` verifies every payload binary through
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
brand-new version can still show SmartScreen until the signature accumulates
reputation: *More info → Run anyway*.
- **macOS** — still unsigned and un-notarized. Open
`Reasonix-darwin-universal.dmg`, drag Reasonix into Applications, then clear the
quarantine attribute when Gatekeeper reports the app "is damaged" or is from an
unidentified developer:
```sh
xattr -dr com.apple.quarantine /Applications/Reasonix.app
```
- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info →
Run anyway*.
When Developer ID / Authenticode certificates are added, the release workflow's
`HAS_APPLE_CERT` gate flips to the signed path and these steps go away.
This is also why macOS has no in-place self-update: the swap would be blocked.
Adding a Developer ID certificate flips the release workflow's `HAS_APPLE_CERT`
gate to the signed path and removes both.
### Verifying a download
+659 -221
View File
File diff suppressed because it is too large. Load diff
+193 -9
View File
@@ -24,6 +24,7 @@ import (
"reasonix/internal/billing"
"reasonix/internal/boot"
"reasonix/internal/bot"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -35,6 +36,7 @@ import (
"reasonix/internal/plugin"
"reasonix/internal/pluginpkg"
"reasonix/internal/provider"
"reasonix/internal/remote/protocol"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
"reasonix/internal/store"
@@ -372,9 +374,12 @@ func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
return r.ask(ctx)
}
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
func TestCommandsIncludesDocsAndEffortNotThinking(t *testing.T) {
app := NewApp()
cmds := app.Commands()
if !hasCommand(cmds, "docs") {
t.Fatalf("Commands() should include docs: %+v", cmds)
}
if !hasCommand(cmds, "effort") {
t.Fatalf("Commands() should include effort: %+v", cmds)
}
@@ -383,6 +388,153 @@ func TestCommandsIncludesEffortNotThinking(t *testing.T) {
}
}
func TestCommandsDocsShowsOnlyRuntimeWinner(t *testing.T) {
tests := []struct {
name string
commands []command.Command
skills []skill.Skill
wantKind string
}{
{
name: "custom command shadows builtin",
commands: []command.Command{{Name: "docs", Description: "custom docs"}},
wantKind: "custom",
},
{
name: "skill shadows builtin",
skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
wantKind: "skill",
},
{
name: "custom command shadows skill and builtin",
commands: []command.Command{{Name: "docs", Description: "custom docs"}},
skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
wantKind: "custom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
var docs []CommandInfo
for _, cmd := range app.Commands() {
if cmd.Name == "docs" {
docs = append(docs, cmd)
}
}
if len(docs) != 1 || docs[0].Kind != tt.wantKind {
t.Fatalf("docs commands = %+v, want one %s entry", docs, tt.wantKind)
}
if fallback, ok := commandInfoByName(app.Commands(), control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" {
t.Fatalf("qualified docs fallback = %+v, %v; want built-in", fallback, ok)
}
})
}
}
func TestCommandsDocsAccountsForHiddenCompatibilityAliases(t *testing.T) {
tests := []struct {
name string
commands []command.Command
skills []skill.Skill
wantCanonical string
}{
{
name: "hidden plugin command alias",
commands: []command.Command{
{Name: "docs", Plugin: "manuals", Hidden: true},
{Name: "manuals:docs", Plugin: "manuals"},
},
wantCanonical: "manuals:docs",
},
{
name: "compatible plugin skill alias",
skills: []skill.Skill{{Name: "docs", Plugin: "manuals"}},
wantCanonical: "manuals:docs",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
commands := app.Commands()
if _, ok := commandInfoByName(commands, "docs"); ok {
t.Fatalf("hidden runtime owner left a misleading docs entry: %+v", commands)
}
for _, want := range []string{control.ReasonixDocsSlashName, tt.wantCanonical} {
if _, ok := commandInfoByName(commands, want); !ok {
t.Fatalf("commands missing %q: %+v", want, commands)
}
}
})
}
}
func TestCommandsDocsDoesNotDisplaceQualifiedCustomCommands(t *testing.T) {
ctrl := control.New(control.Options{Commands: []command.Command{
{Name: "docs", Description: "custom docs"},
{Name: "reasonix:docs", Description: "qualified custom docs"},
{Name: "reasonix:builtin:docs", Description: "second qualified custom docs"},
}})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
commands := app.Commands()
for _, want := range []struct {
name string
kind string
}{
{name: "docs", kind: "custom"},
{name: "reasonix:docs", kind: "custom"},
{name: "reasonix:builtin:docs", kind: "custom"},
{name: "reasonix:builtin:docs:2", kind: "builtin"},
} {
if command, ok := commandInfoByName(commands, want.name); !ok || command.Kind != want.kind {
t.Fatalf("command %q = %+v, %v; want kind %q", want.name, command, ok, want.kind)
}
}
}
func TestAppendRemoteSessionCommandsUsesHostResolvedDocsName(t *testing.T) {
base := []CommandInfo{{Name: "new", Kind: "builtin", Group: "actions"}}
legacy := appendRemoteSessionCommands(base, protocol.SessionCatalogResult{})
if _, ok := commandInfoByName(legacy, "docs"); ok {
t.Fatalf("new desktop invented /docs for an older host: %+v", legacy)
}
catalog := protocol.SessionCatalogResult{
BuiltinCommands: []protocol.BuiltinCommandCatalogItem{{
Name: control.ReasonixDocsSlashName, Description: "host docs", Hint: "<question>", Group: "integrations",
}},
Commands: []protocol.CommandCatalogItem{{Name: "manuals:docs", Description: "plugin docs"}},
Skills: []protocol.SkillCatalogItem{},
}
commands := appendRemoteSessionCommands(base, catalog)
if _, ok := commandInfoByName(commands, "docs"); ok {
t.Fatalf("remote commands invented an unavailable short docs route: %+v", commands)
}
if fallback, ok := commandInfoByName(commands, control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" || fallback.Hint != "<question>" {
t.Fatalf("remote qualified docs fallback = %+v, %v", fallback, ok)
}
if canonical, ok := commandInfoByName(commands, "manuals:docs"); !ok || canonical.Kind != "custom" {
t.Fatalf("remote canonical plugin docs = %+v, %v", canonical, ok)
}
}
func commandInfoByName(commands []CommandInfo, name string) (CommandInfo, bool) {
for _, command := range commands {
if command.Name == name {
return command, true
}
}
return CommandInfo{}, false
}
func TestCommandsClassifiesSubagentSkills(t *testing.T) {
ctrl := control.New(control.Options{Skills: []skill.Skill{
{Name: "init", Description: "inline skill", RunAs: skill.RunInline},
@@ -996,6 +1148,9 @@ status_bar_items = ["cost", "balance"]
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
t.Fatalf("set desktop terminal theme: %v", err)
}
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
t.Fatalf("set desktop close behavior: %v", err)
}
@@ -1016,7 +1171,7 @@ status_bar_items = ["cost", "balance"]
}
got := NewApp().Settings()
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)
}
if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -1037,6 +1192,9 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
t.Fatalf("set desktop terminal theme: %v", err)
}
if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
t.Fatalf("set desktop status bar style: %v", err)
}
@@ -1057,7 +1215,7 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
}
got := NewApp().DesktopStartupSettings()
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "preview" {
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "stable" {
t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
}
if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -5622,6 +5780,17 @@ func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -5630,12 +5799,17 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
path := filepath.Join(dir, "jobs.jsonl")
jm := jobs.NewManager(event.Discard)
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
app.ctx = context.Background()
app.setTestCtrl(ctrl, "old/old-model")
t.Cleanup(func() {
if current := app.activeCtrl(); current != nil {
current.Close()
}
})
release := make(chan struct{})
jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
@@ -5643,11 +5817,21 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
return "", nil
}
})
defer close(release)
t.Cleanup(func() { close(release) })
err := app.SetTokenMode("economy")
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
}
cancelled, err := app.CancelJobForTab("", job.ID)
if err != nil || !cancelled {
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
}
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
t.Fatalf("stopped background job = %+v, want one killed result", result)
}
if err := app.SetTokenMode("economy"); err != nil {
t.Fatalf("SetTokenMode after stopping background job: %v", err)
}
}
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"fmt"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/workspacelease"
)
// ActiveWorkView is the structured Desktop contract for work that prevents a
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
type ActiveWorkView struct {
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Cancellable bool `json:"cancellable"`
Jobs []JobView `json:"jobs"`
}
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
// slices are initialized so Wails never sends null to React.
type JobCancelBatchView struct {
Cancelled []string `json:"cancelled"`
NotRunning []string `json:"notRunning"`
}
// BackgroundRuntimeView is one visible or detached runtime with active work.
// TabID is an opaque process-local handle; paths and session writer ids are
// deliberately omitted from this user-facing contract.
type BackgroundRuntimeView struct {
TabID string `json:"tabId"`
Title string `json:"title"`
Detached bool `json:"detached"`
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Jobs []JobView `json:"jobs"`
}
// WorkspaceConflictView describes a currently-waiting Delivery writer without
// exposing the lock path, process id, or session path.
type WorkspaceConflictView struct {
State string `json:"state"`
OwnerTabID string `json:"ownerTabId,omitempty"`
OwnerTitle string `json:"ownerTitle,omitempty"`
OwnerWork ActiveWorkView `json:"ownerWork"`
CanReveal bool `json:"canReveal"`
CanCreateWorktree bool `json:"canCreateWorktree"`
}
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
view := ActiveWorkView{Jobs: []JobView{}}
if ctrl == nil {
return view
}
status := ctrl.RuntimeStatus()
view.Running = status.Running
view.PendingPrompt = status.PendingPrompt
view.Cancellable = status.Cancellable
for _, job := range ctrl.Jobs() {
view.Jobs = append(view.Jobs, JobView{
ID: job.ID, Kind: job.Kind, Label: job.Label,
Status: job.Status, StartedAt: job.StartedAt,
})
}
return view
}
func (v ActiveWorkView) active() bool {
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
}
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
// preflight aid only; rebuild paths still re-check active work atomically.
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
}
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
// keeps cancelled-but-unwinding jobs visible until their done channels close.
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
seen := map[string]bool{}
for _, raw := range jobIDs {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
cancelled, err := a.CancelJobForTab(tabID, id)
if err != nil {
return result, err
}
if cancelled {
result.Cancelled = append(result.Cancelled, id)
} else {
result.NotRunning = append(result.NotRunning, id)
}
}
return result, nil
}
type backgroundRuntimeSnapshot struct {
id string
ctrl control.SessionAPI
detached bool
title string
}
// BackgroundRuntimes returns every process-local runtime that still needs a
// visible recovery path, including runtimes detached by an explicit tab close.
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
a.mu.RLock()
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.tabs {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
}
for _, tab := range a.detachedSessions {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
}
a.mu.RUnlock()
out := make([]BackgroundRuntimeView, 0, len(snapshots))
for _, snapshot := range snapshots {
work := activeWorkForController(snapshot.ctrl)
if !work.active() {
continue
}
out = append(out, BackgroundRuntimeView{
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
})
}
return out
}
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
if strings.TrimSpace(tabID) == "" {
return a.activeCtrlLocked()
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil {
return nil
}
return tab.Ctrl
}
// RevealBackgroundRuntime activates a visible owner or reopens the exact
// detached session. It never reattaches by workspace alone.
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
a.mu.RLock()
if tab := a.tabs[tabID]; tab != nil {
a.mu.RUnlock()
if err := a.SetActiveTab(tabID); err != nil {
return TabMeta{}, err
}
a.mu.RLock()
current := a.tabs[tabID]
if current == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
meta := a.tabMeta(current, true)
a.mu.RUnlock()
return enrichTabMeta(meta), nil
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil || tab.Ctrl == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
sessionPath := tab.currentSessionPath()
a.mu.RUnlock()
if strings.TrimSpace(sessionPath) == "" {
return TabMeta{}, fmt.Errorf("background task session is unavailable")
}
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
}
type workspaceLeaseReporter interface {
WorkspaceLeaseState() workspacelease.State
}
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
return reporter.WorkspaceLeaseState()
}
return workspacelease.State{}
}
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
// currently waiting for. An acquired process-local owner is actionable; when no
// local owner matches, the OS lock is treated as external.
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
a.mu.RLock()
var target *WorkspaceTab
if strings.TrimSpace(tabID) == "" {
target = a.activeTabLocked()
} else {
target = a.tabByEventSinkIDLocked(tabID)
}
if target == nil {
a.mu.RUnlock()
return empty
}
targetCtrl := target.Ctrl
targetWorkspaceRoot := target.WorkspaceRoot
a.mu.RUnlock()
if targetCtrl == nil {
return empty
}
targetState := controllerWorkspaceLeaseState(targetCtrl)
if !targetState.Waiting || targetState.Acquired {
return empty
}
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
if err != nil {
return empty
}
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
a.mu.RLock()
type candidate struct {
id string
ctrl control.SessionAPI
root string
title string
}
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
}
a.mu.RUnlock()
for _, candidate := range candidates {
root, err := workspacelease.CanonicalWorkspace(candidate.root)
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
continue
}
return WorkspaceConflictView{
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
CanCreateWorktree: availability.Available,
}
}
empty.State = "external"
empty.CanCreateWorktree = availability.Available
return empty
}
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
// by WorkspaceConflictForTab. External writers remain non-actionable.
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
conflict := a.WorkspaceConflictForTab(tabID)
if conflict.State != "local" || conflict.OwnerTabID == "" {
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
}
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
}
const stopAndCloseGrace = 15 * time.Second
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
// choice. stop_and_close never removes the tab until all owned work is idle.
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
switch strings.TrimSpace(policy) {
case "keep_running":
return a.closeTab(tabID, true)
case "stop_and_close":
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl == nil {
return a.closeTab(tabID, false)
}
ctrl.Cancel()
jobs := ctrl.Jobs()
ids := make([]string, 0, len(jobs))
for _, job := range jobs {
ids = append(ids, job.ID)
}
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
return err
}
deadline := time.NewTimer(stopAndCloseGrace)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if !activeWorkForController(ctrl).active() {
return a.closeTab(tabID, false)
}
select {
case <-deadline.C:
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
case <-ticker.C:
}
}
default:
return fmt.Errorf("unknown close policy %q", policy)
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"testing"
"reasonix/internal/control"
"reasonix/internal/jobs"
"reasonix/internal/remote/workbench/client"
"reasonix/internal/workspacelease"
)
type backgroundRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
cancelled []string
turnCancel int
}
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *backgroundRuntimeController) Jobs() []jobs.View {
return append([]jobs.View(nil), c.jobs...)
}
func (c *backgroundRuntimeController) CancelJob(id string) bool {
for _, job := range c.jobs {
if job.ID == id {
c.cancelled = append(c.cancelled, id)
return true
}
}
return false
}
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
return c.lease
}
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
targetCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
}
focusedCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
}
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
app := &App{
tabs: map[string]*WorkspaceTab{"focused": focused},
tabOrder: []string{"focused"},
activeTabID: "focused",
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
}
work := app.ActiveWorkForTab("target")
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
}
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
if err != nil {
t.Fatalf("CancelJobsForTab: %v", err)
}
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
t.Fatalf("CancelJobsForTab result = %+v", result)
}
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
}
runtimes := app.BackgroundRuntimes()
if len(runtimes) != 2 {
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
}
var foundDetached bool
for _, runtime := range runtimes {
if runtime.TabID == "target" {
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
}
}
if !foundDetached {
t.Fatalf("detached runtime missing from %+v", runtimes)
}
}
func TestCancelJobForLocalRuntimeWhileRemoteWorkbenchIsActive(t *testing.T) {
localCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
}
app := &App{
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
tabOrder: []string{"local"},
activeTabID: "local",
}
kernel := app.workbench()
_, generation, err := kernel.targets.BeginRemoteConnect("remote-host", "/srv/work")
if err != nil {
t.Fatalf("BeginRemoteConnect: %v", err)
}
if err := kernel.targets.MarkRemoteConnected(generation); err != nil {
t.Fatalf("MarkRemoteConnected: %v", err)
}
if _, _, _, err := kernel.targets.ActivateRemote(generation); err != nil {
t.Fatalf("ActivateRemote: %v", err)
}
kernel.mu.Lock()
kernel.remote = &client.Client{}
kernel.remoteGen = generation
kernel.remoteTabID = "remote"
kernel.mu.Unlock()
listed := app.JobsForTab("local")
if len(listed) != 1 || listed[0].ID != "job-local" {
t.Fatalf("JobsForTab(local) = %+v, want local job while Remote Workbench is active", listed)
}
cancelled, err := app.CancelJobForTab("local", "job-local")
if err != nil {
t.Fatalf("CancelJobForTab(local): %v", err)
}
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
}
}
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
root := t.TempDir()
waiterCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{Running: true},
lease: workspacelease.State{Waiting: true},
}
ownerCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
lease: workspacelease.State{Acquired: true},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"},
activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
}
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
}
}
+4
View File
@@ -105,6 +105,10 @@ func (f *botEventForwarder) Emit(e event.Event) {
f.sendToAll(qb.String())
case event.Notice:
if e.Audience == event.NoticeAudienceOperator {
// Local runtime maintenance is not actionable in the remote IM chat.
break
}
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
+33
View File
@@ -131,3 +131,36 @@ func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
t.Fatal("approval notice was not sent")
}
}
func TestBotEventForwarderSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
forwarder := &botEventForwarder{
runtime: &desktopBotRuntime{},
targets: []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}},
queue: make(chan string, 2),
}
forwarder.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Audience: event.NoticeAudienceOperator,
Code: event.NoticeCodeSessionRecoveryForked,
Text: "local session maintenance",
})
forwarder.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: "please retry the user action",
})
if got := len(forwarder.queue); got != 1 {
t.Fatalf("queued messages = %d, want only the actionable user warning", got)
}
if got := <-forwarder.queue; got != "⚠️ please retry the user action" {
t.Fatalf("queued message = %q, want the ordinary user warning", got)
}
}
+4 -2
View File
@@ -20,6 +20,8 @@ description: |
vendor: "Reasonix Contributors"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
license: "MIT"
scripts:
postinstall: ./build/linux/postinstall.sh
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
# pkexec is required for the in-app Polkit authorization update path.
@@ -32,8 +34,8 @@ contents:
dst: /usr/bin/reasonix-desktop
file_info:
mode: 0755
- src: ./build/bin/reasonix-guard
dst: /usr/bin/reasonix-guard
- src: ./build/bin/reasonix-launcher
dst: /usr/bin/reasonix-launcher
file_info:
mode: 0755
- src: ./build/bin/reasonix
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Refresh desktop integration after both fresh installs and upgrades. Debian's
# package triggers usually do this too, but an explicit best-effort refresh
# repairs stale blank icons on desktops that kept an older cache.
if [ -x /usr/bin/gtk-update-icon-cache ]; then
/usr/bin/gtk-update-icon-cache --force --quiet /usr/share/icons/hicolor || true
fi
if [ -x /usr/bin/update-desktop-database ]; then
/usr/bin/update-desktop-database --quiet /usr/share/applications || true
fi
exit 0
+1 -1
View File
@@ -2,7 +2,7 @@
Type=Application
Name=Reasonix
Comment=Reasonix desktop — a Wails shell around the Go kernel
Exec=reasonix-guard launch --detach
Exec=reasonix-launcher
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
+82 -38
View File
@@ -100,6 +100,7 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_UNLOCK_RETRIES 60
@@ -213,12 +214,21 @@ Function reasonix.waitForExecutableUnlock
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_guard
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_versioned_target:
; A same-version recovery install replaces this directory transactionally.
; Detect the running active binary before asking the Go activator to rename it.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
@@ -273,72 +283,104 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; Current auto-update helpers extract the signed installer payload into a
; unique temporary directory, then compare-and-publish the exact claimed
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
; in-place update behavior required to upgrade into the hardened release.
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
; the new helper's staging extract). Do not write shortcuts/uninstaller.
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
; with a permanent thin launcher at InstallRoot. Guard is only present in
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
reasonix_copy_payload:
reasonix_stage_payload:
SetOutPath $INSTDIR
; The release workflow embeds a minisign-authenticated manifest after
; SignPath has signed the five executable members. Only the extraction
; handoff needs these files; a normal installation does not persist them.
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
reasonix_payload_metadata_done:
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
; Portable archives expose Reasonix.exe as a second copy of the Guard
; launcher. Preserve that layout only when upgrading an existing portable
; directory, and keep the alias in lockstep with the canonical launcher.
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 reasonix_no_portable_entry
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
reasonix_no_portable_entry:
; Keep the Guard launcher as the target while taking the visible shortcut
; icon from the Wails application. The launcher embeds the same icon too,
; but an explicit icon source prevents stale/generic taskbar pins after an
; in-place upgrade from a launcher build that had no Windows resources.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
reasonix_launcher_payload_done:
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
Goto reasonix_section_done
reasonix_normal_install:
; Extract into an install-local temporary directory, then let the signed Go
; activator validate the complete release unit, transactionally publish the
; version/root entries, and strictly atomically replace current.json last.
; The normal/recovery installer therefore shares the same commit protocol as
; automatic updates instead of writing live files or current.json in place.
System::Call 'kernel32::GetCurrentProcessId() i .R8'
CreateDirectory "$INSTDIR\versions"
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
RMDir /r "$R9"
CreateDirectory "$R9"
SetOutPath "$R9"
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
reasonix_launcher_payload_done_no_launcher:
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!else
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
!endif
SetOutPath "$PLUGINSDIR"
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
!else
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
!endif
ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch' $0
StrCmp $0 "0" reasonix_layout_activated
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
RMDir /r "$R9"
SetErrorLevel 1
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
reasonix_layout_activated:
RMDir /r "$R9"
SetOutPath "$INSTDIR"
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
!if /FileExists "${REASONIX_LAUNCHER}"
; Keep both target and icon on the stable launcher. Pointing IconLocation at
; versions\vX\reasonix-desktop.exe leaves a blank shortcut as soon as version
; retention removes that directory after a later update.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
!endif
StrCmp $ReasonixStageMode "1" reasonix_section_done
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
reasonix_section_done:
SectionEnd
@@ -347,13 +389,15 @@ Section "uninstall"
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
; Precision uninstall: delete main application files
; Precision uninstall: flat leftovers, thin entry points, and version trees.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$INSTDIR\current.json"
RMDir /r "$INSTDIR\versions"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+3 -2
View File
@@ -16,8 +16,9 @@ func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
`SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`,
`SOURCE_REVISION="$SOURCE_REVISION+dirty"`,
`source_revision_ldflag="-X reasonix/internal/remote/protocol.linkedSourceRevision=$SOURCE_REVISION"`,
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag"`,
`-X main.version=$VERSION $source_revision_ldflag`,
`product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"`,
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag $product_docs_ldflags"`,
`-X main.version=$VERSION $source_revision_ldflag $product_docs_ldflags`,
} {
if !strings.Contains(script, want) {
t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want)
+117 -4
View File
@@ -12,8 +12,86 @@ import (
"reasonix/internal/checkpoint"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
dir := t.TempDir()
root := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(root, "a.txt")
if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil {
t.Fatal(err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
diskMode := uint32(fileInfo.Mode().Perm())
before := "before"
afterExists := true
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3,
Coverage: checkpoint.CoverageComplete,
Files: []checkpoint.FileSnap{{
Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: diskMode,
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: diskMode,
CaptureSource: checkpoint.CaptureBeforeMutation,
}},
})
session := agent.NewSession("")
session.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "answer"},
{Role: provider.RoleUser, Content: "edit"},
{Role: provider.RoleAssistant, Content: "done"},
})
if err := session.Save(sessionPath); err != nil {
t.Fatal(err)
}
ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
app := &App{}
app.setTestCtrl(ctrl, "test")
plan := app.PreviewRewindForTab("test", 1, "both")
if !plan.OK || !plan.CanFiles || !plan.CanConversation {
t.Fatalf("preview = %+v", plan)
}
result := app.CommitRewindForTab("test", plan.PlanID, 1, "both")
if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
t.Fatalf("commit = %+v", result)
}
if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
t.Fatalf("file after commit = %q err=%v", got, err)
}
if got := ctrl.History(); len(got) != 3 {
t.Fatalf("controller history after commit = %d, want 3", len(got))
}
if got := app.HistoryForTab("test"); len(got) != 3 {
t.Fatalf("desktop history after commit = %d, want 3", len(got))
}
undo := app.UndoRewindForTab("test", result.TransactionID)
if !undo.OK {
t.Fatalf("undo = %+v", undo)
}
if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
t.Fatalf("file after undo = %q err=%v", got, err)
}
if got := ctrl.History(); len(got) != 5 {
t.Fatalf("controller history after undo = %d, want 5", len(got))
}
if got := app.HistoryForTab("test"); len(got) != 5 {
t.Fatalf("desktop history after undo = %d, want 5", len(got))
}
}
func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
t.Helper()
b, err := json.Marshal(c)
@@ -60,11 +138,16 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
t.Fatal(err)
}
content := "old"
afterExists := true
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
Coverage: checkpoint.CoverageComplete,
Files: []checkpoint.FileSnap{{
Path: "a.txt", Content: &content, SHA256: checkpoint.Digest([]byte(content)),
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("new")), CaptureSource: checkpoint.CaptureBeforeMutation,
}}})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
@@ -108,6 +191,36 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsCanCodeDoesNotReenableLegacySuffix(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "before", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "legacy edit", MsgIndex: 2,
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
for _, meta := range metas {
if meta.CanCode {
t.Fatalf("legacy suffix re-enabled code rewind at turn %d: %+v", meta.Turn, meta)
}
}
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
+143 -32
View File
@@ -19,6 +19,7 @@ import (
"golang.org/x/sys/windows"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -42,7 +43,7 @@ func main() {
func run(args []string) int {
var parentPID uint
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
@@ -53,6 +54,7 @@ func run(args []string) int {
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -69,11 +71,15 @@ func run(args []string) int {
logger.Print("missing --to-version")
return 2
}
if createdAt == "" {
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if transactionID == "" {
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
@@ -87,6 +93,25 @@ func run(args []string) int {
return 1
}
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
@@ -94,13 +119,13 @@ func run(args []string) int {
toVersion,
createdAt,
transactionID,
filepath.Join(installDir, "reasonix-desktop.exe"),
windowsReleaseUnitPaths(installDir),
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return 1
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
@@ -161,29 +186,41 @@ func run(args []string) int {
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(relaunch, installDir); err != nil {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -191,6 +228,86 @@ func run(args []string) int {
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
@@ -328,21 +445,15 @@ func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
if err != nil {
return false, nil, err
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
// Persist recovery intent before the first live rename. If the helper exits
// between release-unit members, Guard rolls the exact pending transaction
// back instead of launching a mixed install.
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
if err != nil {
return true, receipts, err
}
return true, receipts, nil
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
+150 -30
View File
@@ -12,11 +12,27 @@ import (
"testing"
"time"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
@@ -72,8 +88,86 @@ func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := t.TempDir()
seed := t.TempDir()
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
@@ -107,11 +201,11 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, nil, nil
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
@@ -132,16 +226,16 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
"v2",
"2026-07-29T00:00:00Z",
"transaction-1",
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
@@ -197,6 +291,44 @@ func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
@@ -221,12 +353,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
@@ -236,7 +363,6 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
const createdAt = "2026-07-29T00:00:00Z"
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
@@ -244,7 +370,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", createdAt,
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
@@ -280,12 +406,7 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
@@ -296,7 +417,11 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, nil, nil
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
@@ -351,12 +476,7 @@ func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
@@ -0,0 +1,118 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -0,0 +1,129 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := t.TempDir()
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
+23 -1
View File
@@ -22,13 +22,35 @@ func TestParseWebView2ProcessFailure(t *testing.T) {
}
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
report := webView2ProcessFailureReport(2)
report := webView2ProcessFailureReportWithContext(2, 3, "132.0.2957.140")
if report.Source != "webview2.process" || report.Label != "windows.webview2.process_failed" {
t.Fatalf("report = %+v", report)
}
if report.FingerprintHint != "windows.webview2.render_process_unresponsive" {
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
}
for _, want := range []string{"runtime version: 132.0.2957.140", "same-process occurrence: 3", "exit code: unavailable"} {
if !strings.Contains(report.Message, want) {
t.Fatalf("report message missing %q: %s", want, report.Message)
}
}
}
func TestWebView2FailureTrackerDeduplicatesSessionBursts(t *testing.T) {
var tracker webView2FailureTracker
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
if occurrence, report := tracker.observe(2, base); occurrence != 1 || !report {
t.Fatalf("first observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(2, base.Add(time.Minute)); occurrence != 2 || report {
t.Fatalf("burst observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(2, base.Add(webView2FailureReportCooldown)); occurrence != 3 || !report {
t.Fatalf("post-cooldown observation = (%d, %v)", occurrence, report)
}
if occurrence, report := tracker.observe(6, base.Add(time.Minute)); occurrence != 1 || !report {
t.Fatalf("different kind observation = (%d, %v)", occurrence, report)
}
}
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
-6
View File
@@ -146,12 +146,6 @@ func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
// Safe Mode boots from built-in defaults and cannot read the user's real
// telemetry preference, so it must neither send the pending report nor
// consume it: leave the file for the next normal boot to decide.
if config.SafeModeRequested() {
return
}
paths := pendingCrashPaths()
if len(paths) == 0 {
return
+5 -5
View File
@@ -198,7 +198,9 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
}
}
func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
// v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush. With telemetry
// off/default, the pending file is consumed (sent or dropped).
t.Setenv("REASONIX_SAFE_MODE", "1")
oldVersion := version
t.Cleanup(func() {
@@ -209,8 +211,6 @@ func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
writePendingCrash("safe", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if _, ok := readPending(t); !ok {
t.Fatal("safe mode must leave the pending crash file for the next normal boot")
}
// Either sent or dropped is fine; must not retain solely because of Safe Mode env.
// When telemetry is off the file is removed; when on it is sent. Both clear it.
}
+26
View File
@@ -0,0 +1,26 @@
import tsParser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import reactHooks from "eslint-plugin-react-hooks";
export default defineConfig([
{
files: ["src/**/*.{ts,tsx}"],
linterOptions: {
reportUnusedDisableDirectives: "off",
},
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: "latest",
sourceType: "module",
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
},
},
]);
+13 -6
View File
@@ -5,22 +5,26 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
"build": "pnpm lint:hooks && node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
"check:bundle": "node scripts/check-bundle-budget.mjs",
"preview": "vite preview",
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"lint:hooks": "eslint \"src/**/*.{ts,tsx}\"",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/workspace-layout.test.ts",
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && pnpm test:composer-menu-viewport",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/github-link-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && 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__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && 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__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.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__/footer-decision-overflow.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__/shortcuts-recorder-focus.test.tsx && 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__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.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__/context-window-ring.test.tsx && 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__/diagnostics-settings.test.tsx && 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__/delivery-Line truncated
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/workspace-layout.test.ts",
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/github-link-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && 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__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-editor-model-picker.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && 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__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.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__/footer-decision-overflow.test.ts && tsx src/__tests__/workspace-change-status.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__/shortcuts-recorder-focus.test.tsx && 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__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.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__/context-window-ring.test.tsx && 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__/diagnostics-settings.test.tsx && 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/__teLine truncated
"test:remote": "tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && tsx src/__tests__/provider-trust-dialog.test.tsx && tsx src/__tests__/workbench-target.test.ts",
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote && pnpm test:performance"
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
},
"dependencies": {
"@gsap/react": "^2.1.2",
@@ -49,7 +53,10 @@
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/parser": "^8.65.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"mdast-util-math": "^3.0.0",
"terser": "^5.48.0",
+941 -4
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -3,3 +3,4 @@ allowBuilds:
overrides:
mdast-util-gfm-autolink-literal: 2.0.0
undici: 7.29.0
@@ -49,9 +49,11 @@ if (localeChunks.length !== 2) {
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
}
for (const path of localeChunks) {
assertBudget(`${basename(path)} gzip`, gzipBytes(path), 52 * 1024);
const name = basename(path);
const budget = name.startsWith("zh-TW-") ? 53 * 1024 : 52 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0);
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_250 * 1024);
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_263 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024);
+488 -156
View File
@@ -52,11 +52,13 @@ import { TranscriptSelectionMenu } from "./components/TranscriptSelectionMenu";
import { TodoPanel } from "./components/TodoPanel";
import { ApprovalModal } from "./components/ApprovalModal";
import { AskCard } from "./components/AskCard";
import { UndoRewindBanner } from "./components/UndoRewindBanner";
import { ClearContextCard } from "./components/ClearContextCard";
import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
/** Footer decision surface kinds. Priority: tool/plan approval > ask > clear context. */
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "clear_context";
const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner })));
/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "workspace_conflict" | "mode_jobs" | "close_active" | "clear_context";
import { StatusBar } from "./components/StatusBar";
import { RemoteHostKeyDialog } from "./components/RemoteHostKeyDialog";
import { RemoteSecretDialog } from "./components/RemoteSecretDialog";
@@ -67,7 +69,6 @@ import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner";
import { UpdaterProvider } from "./lib/useUpdater";
import { ContextPanel } from "./components/ContextPanel";
import { WorkspacePanel } from "./components/WorkspacePanel";
import { Tooltip } from "./components/Tooltip";
import { StartupSplash } from "./components/StartupSplash";
import { OnboardingOverlay } from "./components/OnboardingOverlay";
@@ -81,6 +82,7 @@ import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { ExternalOpener } from "./components/ExternalOpener";
import { startTerminalEventBridge } from "./lib/terminalEvents";
import { applyTerminalThemePreference } from "./lib/terminalTheme";
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
import { useTerminalStore } from "./store/terminal";
import { parseTodos } from "./lib/tools";
@@ -98,18 +100,22 @@ import {
type BotConnectionView,
type BotRuntimeStatusView,
type BotSettingsView,
type ActiveWorkView,
type BackgroundRuntimeView,
type CollaborationMode,
type ComposerInsertRequest,
type DesktopStartupSettingsView,
type Mode,
modeHasPlan,
type ProjectNode,
type RewindResultView,
type RemoteHostView,
type SessionMeta,
type SettingsView,
type TabMeta,
type TokenMode,
type ToolApprovalMode,
type WorkspaceConflictView,
} from "./lib/types";
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
@@ -279,9 +285,10 @@ function NoticePreviewPanel() {
}
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })));
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
const WorkspacePanel = lazy(() => import("./components/WorkspacePanel").then((module) => ({ default: module.WorkspacePanel })));
const CHAT_MIN_WIDTH = 400;
const CHAT_COMFORT_MIN_WIDTH = 560;
@@ -1087,9 +1094,12 @@ export default function App() {
pickWorkspace,
switchWorkspace,
rewindForTab,
rewindForTabDetailed,
undoRewindForTab,
setModel,
setEffort,
setTokenMode,
cancelJob,
switchTab,
openProjectTab,
createDeliveryWorktree,
@@ -1126,8 +1136,8 @@ export default function App() {
const settingsFocus = useOverlayStore((s) => s.settingsFocus);
const setSettingsFocus = useOverlayStore((s) => s.setSettingsFocus);
const [desktopLayoutStyle, setDesktopLayoutStyle] = useState<DesktopLayoutStyle>("workbench");
const [safeMode, setSafeMode] = useState(false);
const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation";
const [configLoadWarnings, setConfigLoadWarnings] = useState<string[]>([]);
const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState<boolean | null>(null);
const [histView, setHistView] = useState<HistoryViewState | null>(null);
const paletteOpen = useOverlayStore((s) => s.paletteOpen);
@@ -1295,6 +1305,16 @@ export default function App() {
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
const [clearContextPending, setClearContextPending] = useState(false);
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
const [pendingModeSwitch, setPendingModeSwitch] = useState<{
tabId: string;
target: TokenMode;
previous: TokenMode;
work: ActiveWorkView;
stopping: boolean;
} | null>(null);
const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null);
const topicRenameSkipCommitRef = useRef(false);
const prevDecisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
const decisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
@@ -1311,6 +1331,55 @@ export default function App() {
document.documentElement.setAttribute("data-platform", desktopPlatform);
}, [desktopPlatform]);
const refreshBackgroundRuntimes = useCallback(async () => {
try {
setBackgroundRuntimes(await app.BackgroundRuntimes());
} catch {
// The global recovery entry is supplementary; the active-tab job list
// remains available if an older Remote Workbench does not implement it.
}
}, []);
useEffect(() => {
let disposed = false;
const refresh = async () => {
if (disposed) return;
await refreshBackgroundRuntimes();
};
void refresh();
const timer = window.setInterval(() => void refresh(), 1000);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [refreshBackgroundRuntimes]);
useEffect(() => {
if (!activeTabId || !state.running) {
setWorkspaceConflict(null);
return;
}
let disposed = false;
const inspect = async () => {
try {
const conflict = await app.WorkspaceConflictForTab(activeTabId);
if (!disposed) setWorkspaceConflict(conflict.state === "none" ? null : conflict);
} catch {
if (!disposed) setWorkspaceConflict(null);
}
};
void inspect();
const timer = window.setInterval(() => void inspect(), 500);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [activeTabId, state.running]);
useEffect(() => {
if (pendingModeSwitch && pendingModeSwitch.tabId !== activeTabId) setPendingModeSwitch(null);
}, [activeTabId, pendingModeSwitch]);
const closeTransientOverlays = useCallback(() => {
setTransientOverlayDismissSignal((signal) => signal + 1);
}, []);
@@ -1413,10 +1482,11 @@ export default function App() {
}, []);
const applyDesktopPreferences = useCallback(
(settings: Pick<SettingsView, "desktopTheme" | "desktopThemeStyle" | "desktopLayoutStyle" | "desktopLanguage" | "checkUpdates" | "statusBarStyle" | "statusBarItems" | "conversationWidth">) => {
(settings: Pick<SettingsView, "desktopTheme" | "desktopThemeStyle" | "desktopTerminalTheme" | "desktopLayoutStyle" | "desktopLanguage" | "checkUpdates" | "statusBarStyle" | "statusBarItems" | "conversationWidth">) => {
const nextTheme = normalizeThemePreference(settings.desktopTheme);
const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme);
applyConfiguredBaseAppearance(nextTheme, nextStyle);
applyTerminalThemePreference(settings.desktopTerminalTheme);
applyConversationWidth(settings.conversationWidth);
const nextLayoutStyle = normalizeDesktopLayoutStyle(settings.desktopLayoutStyle);
setDesktopLayoutStyle(nextLayoutStyle);
@@ -1445,14 +1515,16 @@ export default function App() {
]);
if (cancelled) return;
applyDesktopPreferences(settings);
setSafeMode(settings.safeMode === true);
setConfigLoadWarnings(
Array.isArray(settings.configWarnings)
? settings.configWarnings.filter((w): w is string => typeof w === "string" && w.trim() !== "")
: [],
);
hydrateDisplayMode(settings.displayMode);
setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
// Load unified theme experience after base appearance so pack tokens win.
if (settings.safeMode === true) {
clearThemePack();
} else {
{
try {
const { loadThemeExperience, applyExperienceToDOM } = await import("./lib/themeExperience");
const exp = await loadThemeExperience();
@@ -1688,9 +1760,12 @@ export default function App() {
return state.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
}
if (state.ask) return "ask";
if (workspaceConflict) return "workspace_conflict";
if (pendingModeSwitch) return "mode_jobs";
if (pendingClose) return "close_active";
if (clearContextPending) return "clear_context";
return null;
}, [clearContextPending, state.approval, state.ask]);
}, [clearContextPending, pendingClose, pendingModeSwitch, state.approval, state.ask, workspaceConflict]);
decisionSurfaceRef.current = decisionSurface;
useEffect(() => {
// Close composer menus/popovers when a decision takes over the footer.
@@ -1872,21 +1947,19 @@ export default function App() {
},
[activeTabId, applyGoalForTab],
);
const applyTokenMode = useCallback(
async (m: TokenMode): Promise<void> => {
const tabId = activeTabId;
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
const previous = composerProfile.tokenMode;
const commitTokenModeSwitch = useCallback(
async (tabId: string, m: TokenMode, previous: TokenMode, profile: ComposerProfile): Promise<void> => {
if (!tabId || activeTabIdRef.current !== tabId || runtimeTransitionTabsRef.current.has(tabId)) return;
runtimeTransitionTabsRef.current.add(tabId);
setRuntimeTransitionsByTab((current) => ({ ...current, [tabId]: true }));
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, composerProfile, { tokenMode: m }, ["tokenMode"]));
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, profile, { tokenMode: m }, ["tokenMode"]));
const switched = await setTokenMode(m);
if (!switched) {
setComposerProfilesByTab((current) => {
const profile = current[tabId] ?? composerProfile;
const pending = { ...profile.pending };
const currentProfile = current[tabId] ?? profile;
const pending = { ...currentProfile.pending };
delete pending.tokenMode;
return { ...current, [tabId]: { ...profile, tokenMode: previous, pending } };
return { ...current, [tabId]: { ...currentProfile, tokenMode: previous, pending } };
});
}
runtimeTransitionTabsRef.current.delete(tabId);
@@ -1897,8 +1970,64 @@ export default function App() {
return next;
});
},
[activeTabId, composerProfile, setTokenMode],
[setTokenMode],
);
const applyTokenMode = useCallback(
async (m: TokenMode): Promise<void> => {
const tabId = activeTabId;
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
const previous = composerProfile.tokenMode;
try {
const work = await app.ActiveWorkForTab(tabId);
if (work.jobs.length > 0) {
setPendingModeSwitch({ tabId, target: m, previous, work, stopping: false });
return;
}
} catch {
// The atomic backend guard remains authoritative on older runtimes.
}
await commitTokenModeSwitch(tabId, m, previous, composerProfile);
},
[activeTabId, commitTokenModeSwitch, composerProfile],
);
const stopJobsAndSwitchMode = useCallback(async () => {
const request = pendingModeSwitch;
if (!request || request.stopping) return;
setPendingModeSwitch({ ...request, stopping: true });
try {
await app.CancelJobsForTab(request.tabId, request.work.jobs.map((job) => job.id));
const deadline = Date.now() + 15_000;
let work = await app.ActiveWorkForTab(request.tabId);
while (work.jobs.length > 0 && Date.now() < deadline) {
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work } : current);
await new Promise((resolve) => window.setTimeout(resolve, 150));
work = await app.ActiveWorkForTab(request.tabId);
}
if (work.jobs.length > 0) {
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work, stopping: false } : current);
showToast(t("status.jobStopFailed"), "error");
return;
}
setPendingModeSwitch(null);
await refreshBackgroundRuntimes();
if (activeTabIdRef.current === request.tabId) {
await commitTokenModeSwitch(request.tabId, request.target, request.previous, composerProfile);
}
} catch (err) {
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [commitTokenModeSwitch, composerProfile, pendingModeSwitch, refreshBackgroundRuntimes, showToast, t]);
const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise<boolean> => {
try {
const cancelled = await app.CancelJobForTab(tabId, jobId);
await refreshBackgroundRuntimes();
return cancelled;
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
return false;
}
}, [refreshBackgroundRuntimes, showToast]);
// Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the
// tool-permission axis while preserving the Ask/Auto base mode.
const cycleMode = useCallback(() => {
@@ -3076,6 +3205,16 @@ export default function App() {
[isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, switchTab],
);
const revealBackgroundRuntime = useCallback(async (tabId: string): Promise<void> => {
try {
const meta = await app.RevealBackgroundRuntime(tabId);
await switchTab(meta.id, meta);
await refreshTabMetas(undefined, { afterMutation: true });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [refreshTabMetas, showToast, switchTab]);
const handleTabChange = useCallback((id: string) => {
closeTransientOverlays();
const selected = tabMetas.find((tab) => tab.id === id);
@@ -3084,8 +3223,16 @@ export default function App() {
setTabRevealSignal((signal) => signal + 1);
}, [closeTransientOverlays, enqueueTabSwitch, tabMetas]);
const handleTabClose = useCallback(async (id: string) => {
const finishTabClose = useCallback(async (
id: string,
policy: "keep_running" | "stop_and_close",
): Promise<boolean> => {
closeTransientOverlays();
const closed = await closeTab(id, policy);
if (!closed) {
showToast(t("runtime.closeFailed"), "error");
return false;
}
setComposerProfilesByTab((current) => {
if (!(id in current)) return current;
const next = { ...current };
@@ -3103,10 +3250,58 @@ export default function App() {
const nextActiveId = remaining[nextIndex]?.id;
return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId }));
});
await closeTab(id);
await refreshTabMetas(undefined, { afterMutation: true });
await refreshBackgroundRuntimes();
setTabRevealSignal((signal) => signal + 1);
}, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]);
return true;
}, [activeTabId, closeTab, closeTransientOverlays, refreshBackgroundRuntimes, refreshTabMetas, showToast, t]);
const handleTabClose = useCallback(async (id: string) => {
try {
const work = await app.ActiveWorkForTab(id);
if (work.running || work.pendingPrompt || work.jobs.length > 0) {
setPendingClose({ tabId: id, work, stopping: false });
return;
}
} catch {
// CloseTabWithPolicy re-checks the controller state atomically.
}
await finishTabClose(id, "stop_and_close");
}, [finishTabClose]);
const resolvePendingClose = useCallback(async (policy: "keep_running" | "stop_and_close") => {
const request = pendingClose;
if (!request || request.stopping) return;
if (policy === "stop_and_close") setPendingClose({ ...request, stopping: true });
const closed = await finishTabClose(request.tabId, policy);
if (closed) setPendingClose(null);
else setPendingClose((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
}, [finishTabClose, pendingClose]);
const revealWorkspaceWriter = useCallback(async () => {
if (!activeTabId) return;
try {
const meta = await app.RevealWorkspaceWriterForTab(activeTabId);
setWorkspaceConflict(null);
await switchTab(meta.id, meta);
await refreshTabMetas(undefined, { afterMutation: true });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [activeTabId, refreshTabMetas, showToast, switchTab]);
const continueInDeliveryWorktree = useCallback(async () => {
const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd;
if (!root) return;
cancel();
setWorkspaceConflict(null);
try {
await createDeliveryWorktree(root);
await refreshTabMetas(undefined, { afterMutation: true });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
}, [cancel, createDeliveryWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => {
closeTransientOverlays();
@@ -3114,7 +3309,15 @@ export default function App() {
const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index);
if (targets.length === 0) return;
for (const id of targets) {
await closeTab(id);
let work: ActiveWorkView | null = null;
try {
work = await app.ActiveWorkForTab(id);
} catch { /* the close path remains authoritative */ }
if (work && (work.running || work.pendingPrompt || work.jobs.length > 0)) {
setPendingClose({ tabId: id, work, stopping: false });
return;
}
await finishTabClose(id, "stop_and_close");
}
if (nextActiveTabId && currentIds.includes(nextActiveTabId)) {
const selected = tabMetas.find((tab) => tab.id === nextActiveTabId);
@@ -3123,7 +3326,7 @@ export default function App() {
}
await refreshTabMetas(undefined, { afterMutation: true });
setTabRevealSignal((signal) => signal + 1);
}, [closeTab, closeTransientOverlays, enqueueTabSwitch, refreshTabMetas, tabMetas]);
}, [closeTransientOverlays, enqueueTabSwitch, finishTabClose, refreshTabMetas, tabMetas]);
const handleTabsReorder = useCallback(async (ids: string[]) => {
setTabOrderIds(ids);
@@ -3139,18 +3342,16 @@ export default function App() {
const [rewindSignal, setRewindSignal] = useState(0);
// ── Optimistic rewind ─────────────────────────────────────────────────
// Rewind is optimistic: the UI immediately truncates, scrolls to the
// target, fills the composer, and shows an undo banner. The real Go
// Rewind is deferred until the user SENDS a new message. Undo simply
// restores the full items list — no Go call needed.
// ── Immediate rewind ──────────────────────────────────────────────────
// On confirm, call Go prepare+commit immediately. Only after success does
// the UI truncate the transcript, refresh files, and fill the composer.
// Real backend undo uses UndoRewindForTab when a transaction id is available.
type RewindState = {
turn: number;
scope: string;
fullItems: Item[]; // pre-truncation items (for undo)
boundaryIdx: number; // first item index of the rewound-to turn
turnDiff: number; // turns rolled back
prompt: string; // user message text for composer fill
transactionId?: string;
undoAvailable?: boolean;
filesRestored?: string[];
filesRemoved?: string[];
};
const [rewindStatesByTab, setRewindStatesByTab] = useState<Record<string, RewindState>>({});
const rewindStatesByTabRef = useRef(rewindStatesByTab);
@@ -3177,19 +3378,40 @@ export default function App() {
});
}, []);
const handleSessionRevertCommitted = useCallback((sourceTabId: string, outcome: RewindResultView) => {
if (!sourceTabId || !outcome.ok) return;
setRewindStateForTab(sourceTabId, {
turnDiff: 0,
transactionId: outcome.transactionId,
undoAvailable: outcome.undoAvailable,
filesRestored: outcome.written ?? [],
filesRemoved: outcome.deleted ?? [],
});
setDockRefreshKey((value) => value + 1);
setProjectRevision((value) => value + 1);
}, [setRewindStateForTab]);
const hydratePlaceholderActive = Boolean(
state.hydrating &&
state.items.length === 0 &&
state.hydratePlaceholderItems?.length,
);
const transcriptHydrating = state.hydrating && !state.hydrateHistoryLoaded;
// Creation hero only after history hydration settles on a truly empty session.
// Avoid flash while switching tabs: items may be empty while placeholders show.
// Exclude IM/Bot detail: hero CSS collapses .main, which also hosts that panel.
// (desktopLayoutStyle is available here; sidebarCreation is declared later.)
const creationEmptyHero =
desktopLayoutStyle === "creation" &&
!sidebarImDetailConnection &&
!sessionHasContent &&
!transcriptHydrating &&
!hydratePlaceholderActive;
const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items;
// Display items: truncated when an optimistic rewind is pending.
const displayItems = useMemo(() => {
if (!rewindState) return transcriptItems;
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
}, [transcriptItems, rewindState]);
// Display items: backend history is authoritative after immediate commit.
// rewindState only drives the undo banner, not optimistic truncation.
const displayItems = transcriptItems;
const latestGuidanceConsumed = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const item = state.items[i];
@@ -3200,7 +3422,8 @@ export default function App() {
return null;
}, [state.items]);
// send wrapper: commits any pending optimistic rewind before sending.
// send wrapper: clear local undo banner state before sending a new turn
// (new mutation invalidates undo). Rewind itself already committed immediately.
const commitThenSend = useCallback(async (
sourceTabId: string,
displayText: string,
@@ -3223,31 +3446,12 @@ export default function App() {
) {
throw new Error(sourceTab.runtime?.issue?.message || sourceTab.startupErr || t("composer.workspaceStarting"));
}
const rs = rewindStatesByTabRef.current[sourceTabId];
if (rs) {
// New turn invalidates the last undo slot.
if (rewindStatesByTabRef.current[sourceTabId]) {
setRewindStateForTab(sourceTabId, null);
setRewindCommittingForTab(sourceTabId, true);
let ok = false;
try {
ok = await rewindForTab(sourceTabId, rs.turn, rs.scope);
} finally {
setRewindCommittingForTab(sourceTabId, false);
}
if (!ok) {
// Rewind failed: the Go conversation is intact. Do not send; the
// controller emits a notice with the reason.
setRewindStateForTab(sourceTabId, rs);
throw new Error(t("rewind.failed"));
}
setRewindSignal((v) => v + 1);
if (rs.scope === "both") {
// Code was only reverted now (deferred), so refresh the dock here.
setDockRefreshKey((v) => v + 1);
setProjectRevision((v) => v + 1);
}
}
await sendToTab(sourceTabId, displayText, submitText, undefined, structured, initialGoal);
}, [rewindForTab, sendToTab, setRewindCommittingForTab, setRewindStateForTab, t, tabMetas]);
}, [sendToTab, setRewindStateForTab, t, tabMetas]);
const handleTranscriptPrompt = useCallback((text: string) => {
if (!activeTabId || !controllerReady) return;
@@ -3285,8 +3489,17 @@ export default function App() {
// Code-only rewind only affects files — no message truncation,
// no optimistic UI needed. Execute immediately.
if (scope === "code") {
rewindForTab(sourceTabId, turn, scope).then((ok) => {
if (!ok) return;
setRewindCommittingForTab(sourceTabId, true);
void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
setRewindCommittingForTab(sourceTabId, false);
if (!outcome.ok) return;
setRewindStateForTab(sourceTabId, {
turnDiff: 0,
transactionId: outcome.transactionId,
undoAvailable: outcome.undoAvailable,
filesRestored: outcome.written ?? [],
filesRemoved: outcome.deleted ?? [],
});
setDockRefreshKey((v) => v + 1);
setProjectRevision((v) => v + 1);
});
@@ -3334,28 +3547,36 @@ export default function App() {
const prevUserCount = items.filter((it) => it.kind === "user").length;
const turnDiff = prevUserCount - targetUserCount;
// Save full items for undo.
const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract<Item, { kind: "user" }> : undefined;
const prompt = userItem?.text ?? "";
setRewindStateForTab(sourceTabId, {
turn,
scope,
fullItems: items,
boundaryIdx,
turnDiff,
prompt,
// Immediate backend commit — only update UI after success.
setRewindCommittingForTab(sourceTabId, true);
void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
setRewindCommittingForTab(sourceTabId, false);
if (!outcome.ok) {
// Keep conversation/files as-is; notices already carry the reason.
return;
}
setRewindStateForTab(sourceTabId, {
turnDiff,
transactionId: outcome.transactionId,
undoAvailable: outcome.undoAvailable,
filesRestored: outcome.written ?? [],
filesRemoved: outcome.deleted ?? [],
});
const insertId = Date.now();
setComposerInsertRequestsByTab((current) => ({
...current,
[sourceTabId]: { id: insertId, text: prompt, mode: "replace" },
}));
setRewindSignal((v) => v + 1);
if (scope === "both" || scope === "code") {
setDockRefreshKey((v) => v + 1);
setProjectRevision((v) => v + 1);
}
});
// Fill composer with the rewound-to user message.
const insertId = Date.now();
setComposerInsertRequestsByTab((current) => ({
...current,
[sourceTabId]: { id: insertId, text: prompt, mode: "replace" },
}));
setRewindSignal((v) => v + 1);
}, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, refreshTabMetas, setRewindStateForTab]);
}, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, rewindForTabDetailed, refreshTabMetas, setRewindStateForTab, setRewindCommittingForTab]);
const handleEditPrompt = useCallback(async (turn: number, displayText: string, submitText?: string): Promise<boolean> => {
const sourceTabId = activeTabId;
@@ -3904,8 +4125,12 @@ export default function App() {
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (!windowsFramelessChrome) return;
// The Wails drag runtime ignores anything with detail !== 1, so a double click
// on a --wails-draggable region never reaches the OS. Both platforms that hide
// their native title bar need this handled here.
const chromeDoubleClickZooms = windowsFramelessChrome || desktopPlatform === "darwin";
const handleChromeTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (!chromeDoubleClickZooms) return;
const target = event.target as HTMLElement | null;
if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return;
if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
@@ -3913,7 +4138,7 @@ export default function App() {
void app.ToggleMaximiseMainWindow()
.then(() => window.setTimeout(syncMainWindowMaximised, 80))
.catch(() => undefined);
}, [syncMainWindowMaximised, windowsFramelessChrome]);
}, [chromeDoubleClickZooms, syncMainWindowMaximised]);
// Creation keeps the classic sidebar/chat structure while gating chrome tweaks
// behind its own style flag so classic/workbench remain unchanged.
const appChromeHidden = sidebarWorkbench || sidebarCreation;
@@ -3931,7 +4156,7 @@ export default function App() {
<TextSizeHotkeys />
<div
ref={appRef}
onDoubleClickCapture={handleWindowsTitlebarDoubleClick}
onDoubleClickCapture={handleChromeTitlebarDoubleClick}
className={[
"app",
`app--${desktopPlatform}`,
@@ -4231,7 +4456,7 @@ export default function App() {
</button>
)}
<section className={`chat-pane${sidebarCreation && !sessionHasContent ? " chat-pane--creation-empty" : ""}`}>
<section className={`chat-pane${creationEmptyHero ? " chat-pane--creation-empty" : ""}`}>
<>
<header className="topicbar">
{workbenchChromeHidden && (
@@ -4318,23 +4543,8 @@ export default function App() {
</div>
<div className="topicbar__spacer" />
<div className="topicbar__actions">
{workbenchChromeHidden && (
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
<button
className={[
"topicbar__chrome-btn",
"topicbar__chrome-btn--workspace",
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={toggleWorkspacePanel}
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
aria-pressed={workspacePanelRenderable}
>
<PanelRight size={15} />
</button>
</Tooltip>
{sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
{!sidebarImDetailConnection && (
<>
@@ -4410,7 +4620,7 @@ export default function App() {
</button>
</Tooltip>
)}
{!sidebarImDetailConnection && activeTab?.scope === "project" && (
{!sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
<Tooltip label={t("shortcuts.cheatsheetTitle")}>
@@ -4442,7 +4652,7 @@ export default function App() {
{!sidebarCreation && <span>{t("topicBar.command")}</span>}
</button>
</Tooltip>
{sidebarCreation && (
{(sidebarCreation || workbenchChromeHidden) && (
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
<button
className={[
@@ -4466,8 +4676,41 @@ export default function App() {
{state.meta?.startupErr && (
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
)}
{safeMode && (
<div className="banner banner--warning">{t("guard.safeMode")}</div>
{configLoadWarnings.length > 0 && (
<div className="banner banner--warning banner--actionable">
<span className="banner__msg" title={configLoadWarnings.join("\n")}>
{t("config.loadWarning", { msg: configLoadWarnings[0] })}
</span>
<span className="banner__spacer" />
<button
type="button"
className="btn btn--small"
onClick={() => void app.OpenUserConfigPath?.().catch(() => {})}
>
{t("config.openConfig")}
</button>
<button
type="button"
className="btn btn--small"
onClick={() => {
void (async () => {
try {
const view = await app.ReloadUserConfig?.();
if (view?.configWarnings) setConfigLoadWarnings(view.configWarnings);
else setConfigLoadWarnings([]);
} catch {
/* keep banner */
}
})();
}}
>
{t("config.reloadConfig")}
</button>
<span className="banner__hint">{t("config.doctorHint")}</span>
<button type="button" className="btn btn--small" onClick={() => setConfigLoadWarnings([])}>
{t("updater.dismiss")}
</button>
</div>
)}
{providerSetupNeeded && !needsOnboarding && (
<div className="banner banner--warning banner--actionable">
@@ -4543,22 +4786,30 @@ export default function App() {
/>
)}
{rewindState && (
<UndoRewindBanner
<Suspense fallback={null}><UndoRewindBanner
meta={{
turns: rewindState.turnDiff,
filesRestored: [], // optimistic: files haven't changed yet
filesRemoved: [],
filesRestored: rewindState.filesRestored ?? [],
filesRemoved: rewindState.filesRemoved ?? [],
onUndo: () => {
if (activeTabId) setRewindStateForTab(activeTabId, null);
if (activeTabId) {
const tabId = activeTabId;
if (!tabId) return;
const tx = rewindState.transactionId;
const undo = tx && rewindState.undoAvailable ? undoRewindForTab(tabId, tx) : Promise.resolve(true);
void undo.then((ok) => {
if (!ok) return;
setRewindStateForTab(tabId, null);
setComposerInsertRequestsByTab((current) => ({
...current,
[activeTabId]: { id: Date.now(), text: "", mode: "replace" },
[tabId]: { id: Date.now(), text: "", mode: "replace" },
}));
}
setRewindSignal((v) => v + 1);
setDockRefreshKey((v) => v + 1);
setProjectRevision((v) => v + 1);
});
},
}}
/>
/></Suspense>
)}
{decisionSurface === "tool_approval" || decisionSurface === "plan_approval"
? state.approval && (
@@ -4608,6 +4859,81 @@ export default function App() {
}}
/>
)
: decisionSurface === "workspace_conflict" && workspaceConflict ? (
<RuntimeDecisionCard
id="workspace-conflict"
title={t("runtime.workspaceConflictTitle")}
badge={t("runtime.workspaceConflictBadge")}
meta={workspaceConflict.state === "local"
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask") })
: t("runtime.workspaceConflictExternal")}
note={t("runtime.workspaceConflictNote")}
onCancel={() => {
cancel();
setWorkspaceConflict(null);
}}
actions={[
...(workspaceConflict.canReveal ? [{
key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"),
onClick: () => void revealWorkspaceWriter(),
}] : []),
...(workspaceConflict.canCreateWorktree ? [{
key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"),
onClick: () => void continueInDeliveryWorktree(),
}] : []),
{
key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"),
onClick: () => { cancel(); setWorkspaceConflict(null); }, danger: true,
},
]}
/>
)
: decisionSurface === "mode_jobs" && pendingModeSwitch ? (
<RuntimeDecisionCard
id="mode-jobs"
title={t("runtime.modeJobsTitle")}
badge={t("status.jobs", { n: pendingModeSwitch.work.jobs.length })}
meta={t("runtime.modeJobsMeta")}
note={pendingModeSwitch.work.jobs.map((job) => job.label || job.kind).join(" · ")}
onCancel={() => setPendingModeSwitch(null)}
actions={[
{
key: "1", label: t("common.cancel"), description: t("runtime.keepModeDesc"),
onClick: () => setPendingModeSwitch(null), disabled: pendingModeSwitch.stopping,
},
{
key: "2", label: pendingModeSwitch.stopping ? t("status.jobStopping") : t("runtime.stopAndSwitch"),
description: t("runtime.stopAndSwitchDesc"), onClick: () => void stopJobsAndSwitchMode(),
danger: true, disabled: pendingModeSwitch.stopping,
},
]}
/>
)
: decisionSurface === "close_active" && pendingClose ? (
<RuntimeDecisionCard
id="close-active"
title={t("runtime.closeTitle")}
badge={t("runtime.closeBadge")}
meta={t("runtime.closeMeta", { n: pendingClose.work.jobs.length })}
note={t("runtime.closeNote")}
onCancel={() => setPendingClose(null)}
actions={[
{
key: "1", label: t("common.cancel"), description: t("runtime.closeCancelDesc"),
onClick: () => setPendingClose(null), disabled: pendingClose.stopping,
},
{
key: "2", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"),
onClick: () => void resolvePendingClose("keep_running"), disabled: pendingClose.stopping,
},
{
key: "3", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"),
description: t("runtime.stopAndCloseDesc"), onClick: () => void resolvePendingClose("stop_and_close"),
danger: true, disabled: pendingClose.stopping,
},
]}
/>
)
: decisionSurface === "clear_context" ? (
<ClearContextCard
onCancel={cancelClearContext}
@@ -4619,11 +4945,18 @@ export default function App() {
{/* Composer stays mounted under a decision so per-session draft
caches (text, attachments, paste blocks, guidance) survive. */}
<div
className={decisionSurface ? "composer-decision-host composer-decision-host--hidden" : "composer-decision-host"}
className={[
"composer-decision-host",
decisionSurface ? "composer-decision-host--hidden" : "",
creationEmptyHero ? "composer-decision-host--creation-hero" : "",
].filter(Boolean).join(" ")}
hidden={Boolean(decisionSurface) || undefined}
inert={decisionSurface ? true : undefined}
aria-hidden={decisionSurface ? true : undefined}
>
{creationEmptyHero && (
<h2 className="welcome-creation__headline">{t("welcome.creation.title")}</h2>
)}
<Composer
running={state.running || rewindCommitting}
collaborationMode={collaborationMode}
@@ -4670,6 +5003,7 @@ export default function App() {
guidanceConsumedText={latestGuidanceConsumed?.text}
guidanceQueuePreviewItems={guidanceQueueMockItems}
showContextWindowRing={sidebarCreation}
heroMode={creationEmptyHero}
context={state.context}
turnCost={state.turnCost}
currency={state.sessionCurrency}
@@ -4753,16 +5087,6 @@ export default function App() {
<span className="workbench-dock__tab-label">{t("rightDock.remote")}</span>
</button>
)}
<button
type="button"
role="tab"
aria-selected={terminalPanelOpen}
className={`workbench-dock__tab${terminalPanelOpen ? " workbench-dock__tab--active" : ""}`}
onClick={toggleTerminalPanel}
>
<TerminalSquare size={13} />
<span className="workbench-dock__tab-label">{t("rightDock.terminal")}</span>
</button>
</div>
</div>
<div className="workbench-dock__body">
@@ -4787,31 +5111,34 @@ export default function App() {
usageSeq={state.usageSeq}
/>
) : (
<WorkspacePanel
open={workspacePanelRenderable}
tabId={activeTabId}
cwd={state.meta?.cwd}
workspaceScopeKey={workspaceScopeKey}
workspaceMemoryKey={workspaceTreeMemoryKey}
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
maximized={workspacePanelMaximized}
panelWidth={workspacePanelRenderWidth}
onClose={() => setWorkspacePanel(false)}
onToggleMaximized={() => {
closeTransientOverlays();
setWorkspacePanelMaximized((value) => !value);
}}
onPreviewModeChange={handleWorkspacePreviewModeChange}
onAddToChat={addWorkspaceTextToComposer}
onAddCodeToChat={addWorkspaceCodeToComposer}
onRequestPanelWidth={ensureWorkspacePanelWidth}
onFileTreeRefresh={refreshComposerFileRefs}
onOpenInTerminal={openTerminalForPath}
refreshKey={dockRefreshKey}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
showViewTabs={false}
creationMode={sidebarCreation}
/>
<Suspense fallback={null}>
<WorkspacePanel
open={workspacePanelRenderable}
tabId={activeTabId}
cwd={state.meta?.cwd}
workspaceScopeKey={workspaceScopeKey}
workspaceMemoryKey={workspaceTreeMemoryKey}
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
maximized={workspacePanelMaximized}
panelWidth={workspacePanelRenderWidth}
onClose={() => setWorkspacePanel(false)}
onToggleMaximized={() => {
closeTransientOverlays();
setWorkspacePanelMaximized((value) => !value);
}}
onPreviewModeChange={handleWorkspacePreviewModeChange}
onAddToChat={addWorkspaceTextToComposer}
onAddCodeToChat={addWorkspaceCodeToComposer}
onRequestPanelWidth={ensureWorkspacePanelWidth}
onFileTreeRefresh={refreshComposerFileRefs}
onSessionRevertCommitted={handleSessionRevertCommitted}
onOpenInTerminal={openTerminalForPath}
refreshKey={dockRefreshKey}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
showViewTabs={false}
creationMode={sidebarCreation}
/>
</Suspense>
)}
</div>
</aside>
@@ -4862,6 +5189,11 @@ export default function App() {
usage={state.usage}
balance={state.balance}
running={state.running || rewindCommitting}
jobs={state.jobs}
onCancelJob={cancelJob}
backgroundRuntimes={backgroundRuntimes}
onCancelRuntimeJob={cancelRuntimeJob}
onRevealRuntime={revealBackgroundRuntime}
sessionTurns={sessionTurns}
sessionTokens={state.sessionTokens}
turnTokens={state.turnTotalTokens}
@@ -16,6 +16,8 @@ const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript
const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8");
const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8");
const workspacePanelSource = readFileSync(resolve(testDir, "../components/WorkspacePanel.tsx"), "utf8");
const rewindCommitSource = readFileSync(resolve(testDir, "../lib/rewindCommit.ts"), "utf8");
const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8");
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
@@ -429,6 +431,18 @@ ok(
"committing optimistic rewind clears only the source tab before awaiting the backend",
);
ok(
/if \(scope === "code"\) \{[\s\S]*?rewindForTabDetailed\(sourceTabId, turn, scope\)[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
"code-only rewind retains the committed transaction id for real undo",
);
ok(
/onSessionRevertCommitted\?\.\(workspaceTabId, result\)/.test(workspacePanelSource) &&
/onSessionRevertCommitted=\{handleSessionRevertCommitted\}/.test(appSource) &&
/handleSessionRevertCommitted[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
"single-file session revert publishes its transaction id to the app undo state",
);
ok(
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
@@ -465,21 +479,49 @@ ok(
/app\.NewSessionForTab\(tabId\)/.test(controllerSource) &&
/app\.ClearSessionForTab\(tabId\)/.test(controllerSource) &&
/app\.CompactForTab\(tabId\)/.test(controllerSource) &&
/app\.RewindForTab\(sourceTabId, turn, actionScope\)/.test(controllerSource) &&
/import\("\.\/rewindCommit"\)/.test(controllerSource) &&
/app\.PreviewRewindForTab\(sourceTabId, turn, scope\)/.test(rewindCommitSource) &&
/app\.CommitRewindForTab\(sourceTabId, remoteLegacy \? "" : \(plan\.planId \|\| ""\), turn, scope\)/.test(rewindCommitSource) &&
/app\.UndoRewindForTab\(sourceTabId, transactionId\)/.test(rewindCommitSource) &&
/app\.ForkForTab\(sourceTabId, turn\)/.test(controllerSource) &&
/app\.SummarizeFromForTab\(sourceTabId, turn\)/.test(controllerSource) &&
/NewSessionForTab\(tabID: string\)/.test(bridgeSource) &&
/CompactForTab\(tabID: string\)/.test(bridgeSource) &&
/RewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource),
/PreviewRewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource) &&
/CommitRewindForTab\(tabID: string, planID: string, turn: number, scope: string\)/.test(bridgeSource) &&
/UndoRewindForTab\(tabID: string, transactionID: string\)/.test(bridgeSource),
"session-changing controller actions use explicit tab-scoped Wails bindings",
);
ok(
/plan\.coverage === "partial"/.test(rewindCommitSource) &&
/window\.confirm\(t\("rewind\.confirmPartialCoverage"/.test(rewindCommitSource) &&
/plan\?\.conflicts\?\.length \? "overwrite_checkpoint" : ""/.test(workspacePanelSource),
"rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
);
ok(
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
/hydrating=\{transcriptHydrating\}/.test(appSource),
"Welcome is suppressed only until transcript history has loaded",
);
ok(
/const creationEmptyHero =/.test(appSource) &&
/!sidebarImDetailConnection/.test(appSource) &&
/!transcriptHydrating/.test(appSource) &&
/!hydratePlaceholderActive/.test(appSource) &&
/chat-pane\$\{creationEmptyHero \? " chat-pane--creation-empty" : ""\}/.test(appSource) &&
/heroMode=\{creationEmptyHero\}/.test(appSource),
"Creation empty hero waits for hydration and skips IM/Bot detail panels",
);
ok(
/if \(heroMode\) \{[\s\S]*?const maxHeight = 96;[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
!/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource),
"Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
);
ok(
/const \[workspaceControllerEpoch, setWorkspaceControllerEpoch\] = useState\(0\);/.test(appSource) &&
/const workspaceScopeKey = \[/.test(appSource) &&
@@ -598,8 +640,15 @@ ok(
ok(
finalDeclaration(".app--windows.app--creation .topicbar", "position") === "relative" &&
finalDeclaration(".app--windows.app--creation .topicbar", "z-index") === "var(--z-app-chrome)" &&
finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "translateY(-4px) !important",
"Windows Creation topic bar lifts its menus above conversation content without changing titlebar alignment",
finalDeclaration(".app--windows.app--creation .topicbar", "min-height") === "40px" &&
finalDeclaration(":root[data-theme-style] .app--windows.app--creation .topicbar", "min-height") === "40px" &&
finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "none !important" &&
finalDeclaration(".app--windows.app--creation .topicbar__title-row", "transform") === "none" &&
finalDeclaration(".app--windows-frameless.app--creation", "--windows-window-controls-height") === "40px" &&
finalDeclaration(".app--creation .topicbar", "min-height") === "56px" &&
finalDeclaration(":root[data-theme-style] .app--creation .topicbar", "padding-top") === "14px" &&
finalDeclaration(".app--creation .topicbar__title-row", "transform") === "translateY(-3px)",
"Windows Creation stays 40px while macOS and Linux keep the shared Creation geometry",
);
for (const selector of [
@@ -639,6 +688,63 @@ ok(
"maximized workbench dock keeps a draggable title region while tabs remain clickable",
);
ok(
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
"active dock underline stays inside the visible dock edge",
);
for (const selector of [
".app--classic .workbench-dock__tab + .workbench-dock__tab::before",
".app--workbench .workbench-dock__tab + .workbench-dock__tab::before",
]) {
ok(
finalDeclaration(selector, "width") === "1px" &&
finalDeclaration(selector, "height") === "16px" &&
finalDeclaration(selector, "background")?.includes("--border-soft"),
`${selector} renders a restrained divider between right-dock tabs`,
);
}
ok(
finalDeclaration(".app--creation .workbench-dock__tab + .workbench-dock__tab::before", "content") === undefined,
"Creation right-dock tabs keep their equal-column treatment without dividers",
);
for (const selector of [
".app--windows-frameless.app--workbench .workbench-dock__tools",
":root[data-theme-style] .app--windows-frameless.app--workbench .workbench-dock__tools",
]) {
const padding = finalDeclaration(selector, "padding") ?? "";
ok(
finalDeclaration(selector, "height") === "calc(40px + var(--windows-window-controls-height))" &&
padding === "var(--windows-window-controls-height) 12px 0" &&
!padding.includes("--windows-window-controls-safe"),
`${selector} keeps dock tabs on a full-width row below Windows controls`,
);
}
for (const selector of [
".app--windows-frameless.app--workbench .workbench-dock__tools::before",
":root[data-theme-style] .app--windows-frameless.app--workbench .workbench-dock__tools::before",
]) {
ok(
finalDeclaration(selector, "top") === "calc(var(--windows-window-controls-height) - 1px)" &&
finalDeclaration(selector, "height") === "1px",
`${selector} separates the Windows title row from the dock tabs`,
);
}
ok(
finalDeclaration(".app--windows-frameless:not(.app--workbench) .workbench-dock__tools", "padding-right") === undefined &&
finalDeclaration(":root[data-theme-style] .app--windows-frameless:not(.app--workbench) .workbench-dock__tools", "padding-right") === undefined,
"classic dock tabs do not reserve native window-control space on their separate chrome row",
);
ok(
/@container \(max-width: 420px\) \{[\s\S]*?\.app--classic \.workbench-dock__tab,[\s\S]*?\.app--workbench \.workbench-dock__tab,[\s\S]*?padding-left:\s*10px;[\s\S]*?padding-right:\s*10px;[\s\S]*?gap:\s*4px;/.test(stylesSource),
"classic and workbench share the same compact four-tab spacing at narrow dock widths",
);
for (const selector of [
":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar",
":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn",
@@ -690,5 +796,17 @@ ok(
"offscreen skip link does not leak its focus shadow into the workbench title area",
);
// The Wails drag runtime drops any mousedown with detail !== 1, so a double
// click on a drag region never reaches the OS: both title-bar-hiding platforms
// have to zoom from here or not at all.
ok(
/chromeDoubleClickZooms\s*=\s*windowsFramelessChrome\s*\|\|\s*desktopPlatform === "darwin"/.test(appSource),
"title-bar double click zooms on macOS as well as frameless Windows",
);
ok(
/handleChromeTitlebarDoubleClick[\s\S]{0,400}?closest\("button, input, textarea, select, a, \[role='button'\], \[role='tab'\], \.windows-window-controls"\)/.test(appSource),
"title-bar double click still ignores interactive controls",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -54,6 +54,25 @@ function installDom() {
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
return this.classList.contains("prompt-action__desc") ? 42 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
if (!this.classList.contains("prompt-action__desc")) return 0;
return this.textContent?.includes("Reuse the archive flow") ? 84 : 42;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value() {
this.setAttribute("data-scrolled-into-view", "true");
},
});
const style = document.createElement("style");
style.textContent = styles;
@@ -77,7 +96,10 @@ console.log("\nask card layout");
header: "Review",
prompt: "baoguanPutArchive needs a user-owned decision: fully align archive logic, or only repair the current compiler error?",
options: [
{ label: "Full alignment", description: "Reuse the archive flow and keep behavior consistent." },
{
label: "Full alignment",
description: "Reuse the archive flow and keep behavior consistent across every constructor, dynamically computed path, runtime boundary, and release validation step.",
},
{ label: "Minimal repair", description: "Touch only the failing path and keep the patch smaller." },
],
},
@@ -99,8 +121,10 @@ console.log("\nask card layout");
});
const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
const content = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
const meta = document.querySelector(".prompt-shelf__meta") as HTMLElement | null;
if (!card || !meta) throw new Error("ask prompt shelf did not render");
const footer = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
if (!card || !content || !meta || !footer) throw new Error("ask prompt shelf did not render");
eq(meta.textContent, ask.questions[0].prompt, "ask question text remains complete in the prompt shelf");
@@ -111,6 +135,10 @@ 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");
ok(document.querySelector(".prompt-shelf--decision") != null, "ask uses the unified decision surface layout");
eq(window.getComputedStyle(card).maxHeight, "min(82vh, 720px)", "Ask card stays bounded by the viewport");
eq(window.getComputedStyle(card).overflow, "hidden", "Ask card delegates overflow to one content scroller");
eq(window.getComputedStyle(content).overflow, "auto", "Ask title, question, and options share one scroll region");
eq(content.contains(footer), false, "Ask confirmation footer stays outside the scrolling content");
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
// options + custom + skip
@@ -119,11 +147,80 @@ console.log("\nask card layout");
optionButtons[0]?.textContent?.includes("Reuse the archive flow") === true,
"option descriptions render inline on each decision row",
);
const actions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
const firstOption = optionButtons[0];
const firstDescription = firstOption?.querySelector(".prompt-action__desc") as HTMLElement | null;
if (!actions || !firstOption || !firstDescription) throw new Error("ask option layout did not render");
const actionsStyle = window.getComputedStyle(actions);
eq(actionsStyle.gridAutoRows, "max-content", "decision rows use their full content height before scrolling");
eq(actionsStyle.alignContent, "start", "decision rows stay content-sized at the top of the scroll region");
eq(actionsStyle.maxHeight, "none", "Ask options do not create a nested scroll region");
eq(actionsStyle.overflow, "visible", "Ask option overflow belongs to the shared content scroller");
const optionStyle = window.getComputedStyle(firstOption);
eq(optionStyle.height, "auto", "long decision rows are not fixed-height");
eq(optionStyle.minHeight, "40px", "short decision rows retain the compact minimum height");
eq(optionStyle.alignItems, "flex-start", "multi-line decision copy aligns with the option key");
const descriptionStyle = window.getComputedStyle(firstDescription);
eq(descriptionStyle.whiteSpace, "normal", "long option descriptions can wrap");
eq(descriptionStyle.display, "-webkit-box", "long Ask descriptions use a multi-line clamp box");
eq(descriptionStyle.overflow, "hidden", "collapsed Ask descriptions stay within three lines");
eq(descriptionStyle.getPropertyValue("-webkit-line-clamp"), "3", "collapsed Ask descriptions show three summary lines");
eq(descriptionStyle.textOverflow, "clip", "wrapped descriptions do not use a single-line ellipsis");
eq(descriptionStyle.overflowWrap, "anywhere", "unspaced option descriptions can wrap without overflowing");
eq(firstOption.getAttribute("title"), ask.questions[0].options[0].description, "collapsed descriptions keep the full native tooltip");
const descriptionToggle = document.querySelector(".prompt-action__description-toggle") as HTMLButtonElement | null;
if (!descriptionToggle) throw new Error("long Ask description disclosure did not render");
eq(descriptionToggle.textContent?.trim(), "View full description", "truncated descriptions expose an explicit full-text action");
eq(descriptionToggle.getAttribute("aria-expanded"), "false", "full description starts collapsed");
eq(descriptionToggle.getAttribute("aria-controls"), firstDescription.id, "disclosure identifies the controlled description");
let disclosureEnterDefaultPrevented = true;
await act(async () => {
const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
descriptionToggle.dispatchEvent(event);
disclosureEnterDefaultPrevented = event.defaultPrevented;
await flushTimers();
});
eq(answers.length, 0, "Enter on Ask disclosure never confirms the selected answer");
eq(disclosureEnterDefaultPrevented, true, "Ask disclosure owns keyboard activation before global shortcuts");
eq(descriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands the Ask description");
await act(async () => {
descriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(descriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses the Ask description again");
await act(async () => {
descriptionToggle.click();
await flushTimers();
});
eq(firstOption.classList.contains("prompt-action--description-expanded"), true, "full-text action expands the selected card");
eq(descriptionToggle.getAttribute("aria-expanded"), "true", "expanded state is announced");
const expandedDescriptionStyle = window.getComputedStyle(firstDescription);
eq(expandedDescriptionStyle.display, "block", "expanded description restores normal block flow");
eq(expandedDescriptionStyle.overflow, "visible", "expanded description reveals the complete text");
await act(async () => {
descriptionToggle.click();
await flushTimers();
});
eq(firstOption.classList.contains("prompt-action--description-expanded"), false, "full description can be collapsed again");
eq(descriptionToggle.getAttribute("aria-expanded"), "false", "collapsed state is announced");
await act(async () => {
optionButtons[1].click();
await flushTimers(200);
});
eq(document.querySelector(".prompt-action__description-toggle"), null, "short selected descriptions do not show a redundant disclosure");
eq(answers.length, 0, "single-select click only selects and does not auto-advance/submit");
await act(async () => {
@@ -245,6 +342,7 @@ console.log("\nask card layout");
});
eq(optionButtons[0]?.getAttribute("aria-selected"), "false", "ArrowDown moves the single-select cursor off the first row");
eq(optionButtons[1]?.getAttribute("aria-selected"), "true", "ArrowDown selects the second option visually");
eq(optionButtons[1]?.getAttribute("data-scrolled-into-view"), "true", "keyboard selection stays inside the visible option viewport");
eq(confirm.disabled, false, "ArrowDown keeps confirm enabled for the highlighted option");
await act(async () => {
@@ -20,6 +20,7 @@ function ok(cond: boolean, label: string) {
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const settingsEntrySource = readFileSync(resolve(here, "../components/SettingsPanelEntry.tsx"), "utf8");
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
@@ -42,10 +43,16 @@ ok(
"App keeps secondary drawers out of the initial chunk",
);
ok(
appSource.includes('import("./components/SettingsPanel")') &&
appSource.includes('import("./components/SettingsPanelEntry")') &&
appSource.includes('import("./components/HistoryPanel")'),
"App loads secondary drawers on demand",
);
ok(
settingsEntrySource.includes('import "./CompactRatioSettings.css"') &&
settingsEntrySource.includes('from "./SettingsPanel"') &&
!settingsSource.includes('import "./CompactRatioSettings.css"'),
"Settings CSS stays in the lazy production entry without breaking direct module tests",
);
ok(
!appSource.includes("openAllHistory") &&
!appSource.includes("cmd-history") &&
@@ -244,7 +244,7 @@ const builtinGroupIndex = subagentsSource.indexOf('aria-labelledby="subagents-bu
ok(customGroupIndex >= 0 && builtinGroupIndex > customGroupIndex, "custom subagents should render before built-in subagents");
ok((subagentsSource.match(/className="subagents-profile-group"/g) ?? []).length === 2, "custom and built-in subagents should use separate sections");
ok(subagentsSource.includes('className="btn btn--small subagents-reset-override"'), "override status and reset should share one compact action");
ok(subagentsStyles.includes("repeat(2, minmax(200px, 1fr)) 152px"), "built-in subagent pickers should use equal columns and reserve one stable status column");
ok(subagentsStyles.includes("repeat(2, minmax(0, 1fr)) 152px"), "built-in subagent pickers should use equal shrinkable columns and reserve one stable status column");
ok(subagentsSource.includes('className="settings-model-picker subagents-effort-picker"'), "effort and model overrides should share the same picker interaction pattern");
ok(subagentsSource.includes("<SubagentInvocation name={skill.name}"), "every subagent card should show its chat invocation affordance");
ok(subagentsSource.includes("onUseInChat(command)"), "subagent cards should send their slash command to the chat composer");
@@ -138,6 +138,43 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
return { root, calls, rerender: paint };
}
function installWindowTimerQueue() {
let clock = 0;
let nextId = 1;
const tasks = new Map<number, { at: number; callback: () => void }>();
const originalSetTimeout = window.setTimeout;
const originalClearTimeout = window.clearTimeout;
window.setTimeout = ((handler: TimerHandler, delay = 0, ...args: unknown[]) => {
if (typeof handler !== "function") throw new Error("string timers are unsupported in tests");
const id = nextId++;
tasks.set(id, { at: clock + Number(delay), callback: () => handler(...args) });
return id;
}) as typeof window.setTimeout;
window.clearTimeout = ((id?: number) => {
if (id !== undefined) tasks.delete(id);
}) as typeof window.clearTimeout;
return {
advance(ms: number) {
clock += ms;
while (true) {
const next = [...tasks.entries()]
.filter(([, task]) => task.at <= clock)
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
if (!next) break;
tasks.delete(next[0]);
next[1].callback();
}
},
restore() {
tasks.clear();
window.setTimeout = originalSetTimeout;
window.clearTimeout = originalClearTimeout;
},
};
}
console.log("\ncomposer run strip");
// Idle: no strip, no stop button, plain send arrow.
@@ -218,6 +255,48 @@ console.log("\ncomposer run strip");
dom.window.close();
}
// A short Creation hover must stay a no-op. In particular, leaving before the
// 120ms open delay must not manufacture a closing-only popover or flash the
// trigger's open styling 140ms later.
{
const dom = installDom();
const timers = installWindowTimerQueue();
const { root } = await renderComposer({ showContextWindowRing: true });
for (const selector of [".composer-task-mode-trigger", ".composer-profile-trigger"]) {
const trigger = document.querySelector(selector) as HTMLButtonElement | null;
if (!trigger) throw new Error(`missing Creation hover trigger: ${selector}`);
await act(async () => {
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
timers.advance(119);
trigger.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }));
timers.advance(140);
});
ok(!trigger.classList.contains(`${selector.slice(1)}--open`), `${selector} stays visually closed after a short hover`);
ok(
document.querySelector(selector.includes("task") ? ".composer-intent-menu" : ".composer-profile-menu") === null,
`${selector} does not render a closing-only menu`,
);
}
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
if (!intentTrigger) throw new Error("missing Creation intent trigger");
await act(async () => {
intentTrigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
timers.advance(120);
});
ok(intentTrigger.classList.contains("composer-task-mode-trigger--open"), "a sustained Creation hover still opens the trigger");
ok(document.querySelector(".composer-intent-menu") !== null, "a sustained Creation hover still renders the menu");
timers.restore();
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Runtime controller transitions disable every mode axis and submit together,
// so rapid Goal + Delivery + approval-mode clicks cannot mutate a half-rebuilt runtime.
{
@@ -241,6 +241,7 @@ const sourceRows = contextSourceRows({
requestCount: 2,
sessionCost: 0.42,
sessionCurrency: "¥",
estimated: true,
},
},
}, "¥");
@@ -258,6 +259,8 @@ eq(
"executor source row exposes input, output, cache hit, cache miss, and request count",
);
eq(sourceRows[1].requests, 1, "planner source row remains visible without cache metadata");
eq(sourceRows[0].estimated, true, "executor source row preserves estimated usage metadata");
eq(sourceRows[1].estimated, false, "missing estimated metadata remains exact for backward compatibility");
eq(sourceRows[1].cacheHitTokens + sourceRows[1].cacheMissTokens, 0, "planner source preserves absent cache metadata as empty");
const executorOnlyRows = contextSourceRows({
@@ -13,6 +13,7 @@ import {
opaqueScriptFingerprintHint,
parseReportedPerf,
performanceLabelForReason,
performanceFingerprintHintForReason,
serializeReportedPerf,
shouldPromptForLongTasks,
shouldPromptForEventLoopLag,
@@ -54,7 +55,7 @@ const legacyObject = function LegacyObject() {} as unknown as ObjectConstructor
installObjectHasOwnPolyfill(legacyObject);
eq(legacyObject.hasOwn?.({ own: true }, "own"), true, "Object.hasOwn polyfill accepts own properties");
eq(legacyObject.hasOwn?.(Object.create({ inherited: true }), "inherited"), false, "Object.hasOwn polyfill rejects inherited properties");
for (const file of ["../components/VirtualMenu.tsx", "../components/WorkspacePanel.tsx", "../components/editors/HljsDiff.tsx"]) {
for (const file of ["../components/VirtualMenu.tsx", "../components/WorkspacePanel.tsx", "../components/editors/HljsDiff.tsx", "../components/editors/LineNumberCode.tsx"]) {
const source = readFileSync(resolve(testDir, file), "utf8");
const fileParts = file.split("/");
const label = fileParts[fileParts.length - 1];
@@ -186,6 +187,14 @@ eq(formatPerformanceContext(perf).includes("long tasks: 3"), true, "formats long
eq(perfPayload.message.includes("event loop lag 1300ms"), true, "payload message keeps lag context");
eq(performanceLabelForReason("long task 900ms"), "performance.longtask", "labels long task pressure");
eq(performanceLabelForReason("js heap 87% of limit"), "performance.heap", "labels heap pressure");
eq(performanceFingerprintHintForReason("js heap 87% of limit"), "frontend.performance.heap.high", "tracks high heap pressure separately");
eq(performanceFingerprintHintForReason("js heap 97% of limit"), "frontend.performance.heap.critical", "tracks critical heap pressure separately");
eq(performanceFingerprintHintForReason("long task 900ms"), undefined, "does not repartition non-heap performance groups");
eq(
buildPerformancePayload({ ...perf, reason: "js heap 97% of limit" }).fingerprintHint,
"frontend.performance.heap.critical",
"adds the heap tier to the report fingerprint",
);
eq(shouldRecordLongTaskSample(14_000, 900, 15_000), false, "ignores startup long tasks before grace ends");
eq(shouldRecordLongTaskSample(16_000, 40, 15_000), false, "ignores short long-task observer entries");
eq(shouldRecordLongTaskSample(16_000, 900, 15_000), true, "records post-grace long tasks");
@@ -3,6 +3,7 @@
// Decision surfaces: ordinary approvals stay select-then-confirm while Plan
// and Auto boundary cards use immediate buttons; no double submit.
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
@@ -10,9 +11,12 @@ import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { ClearContextCard } from "../components/ClearContextCard";
import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
import { LocaleProvider } from "../lib/i18n";
import type { WireApproval } from "../lib/types";
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
let passed = 0;
let failed = 0;
@@ -47,7 +51,7 @@ function flushTimers(ms = 0): Promise<void> {
}
function installDom(language = "en-US") {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
@@ -68,6 +72,27 @@ function installDom(language = "en-US") {
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
return this.classList.contains("prompt-action__desc") ? 42 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
return this.classList.contains("prompt-action__desc") ? 84 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value() {
this.setAttribute("data-scrolled-into-view", "true");
},
});
const style = document.createElement("style");
style.textContent = styles;
document.head.appendChild(style);
return dom;
}
@@ -109,6 +134,14 @@ console.log("\ndecision surface");
ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
const planDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
if (!planDescriptionToggle) throw new Error("Plan description disclosure did not render");
await act(async () => {
planDescriptionToggle.click();
await flushTimers();
});
eq(answers.length, 0, "expanding a Plan description never starts execution");
eq(planDescriptionToggle.getAttribute("aria-expanded"), "true", "Plan disclosure announces its expanded state");
await act(async () => {
actions[1].click();
@@ -224,6 +257,39 @@ console.log("\ndecision surface");
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
eq(actions.length, 4, "ordinary tool approval has four options");
ok(actions[0].classList.contains("prompt-action--selected"), "default selection is allow once");
ok(Boolean(actions[0].title), "tool approval keeps the complete option description in a desktop tooltip");
const toolDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
if (!toolDescriptionToggle) throw new Error("tool approval description disclosure did not render");
const toolContent = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
const toolFooter = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
if (!toolContent || !toolFooter) throw new Error("tool approval scroll layout did not render");
eq(window.getComputedStyle(toolContent).overflow, "auto", "tool approval uses the shared decision scroller");
eq(toolContent.contains(toolFooter), false, "tool approval confirm footer stays visible outside the scroller");
await act(async () => {
toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(answers.length, 0, "Enter on tool disclosure never approves the default permission");
eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands tool approval copy");
await act(async () => {
toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(toolDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses tool approval copy again");
await act(async () => {
toolDescriptionToggle.click();
await flushTimers();
});
eq(answers.length, 0, "expanding tool approval copy never submits the selected permission");
eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "tool approval disclosure announces its expanded state");
await act(async () => {
actions[3].click();
@@ -305,6 +371,22 @@ console.log("\ndecision surface");
ok(guidanceTrigger.textContent?.includes("Tell Auto"), "guidance link uses plain user-facing copy");
ok(!document.querySelector(".recovery-guidance__input"), "custom requirements editor stays collapsed by default");
eq(actions.length, 2, "custom requirements do not become a third decision card");
const recoveryDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
if (!recoveryDescriptionToggle) throw new Error("recovery description disclosure did not render");
const recoveryCard = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__card") as HTMLElement | null;
const recoveryContent = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__content") as HTMLElement | null;
const recoveryActions = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__actions") as HTMLElement | null;
if (!recoveryCard || !recoveryContent || !recoveryActions) throw new Error("recovery height bounds did not render");
eq(window.getComputedStyle(recoveryCard).maxHeight, "min(82vh, 720px)", "recovery card stays bounded by the viewport");
eq(window.getComputedStyle(recoveryCard).overflow, "hidden", "recovery card clips only at its shared scroll boundary");
eq(window.getComputedStyle(recoveryContent).overflow, "auto", "recovery content uses one internal scroller");
eq(window.getComputedStyle(recoveryActions).maxHeight, "none", "recovery options avoid a nested height cap");
eq(window.getComputedStyle(recoveryActions).overflow, "visible", "recovery option text remains available in the shared scroller");
await act(async () => {
recoveryDescriptionToggle.click();
await flushTimers();
});
eq(decisions.length, 0, "expanding recovery details never resolves the pending action");
const taskGrant = document.querySelector(".recovery-task-grant input") as HTMLInputElement;
ok(taskGrant, "bounded recovery offers a current-task semantic grant");
ok(!taskGrant.checked, "task grant is opt-in");
@@ -619,6 +701,126 @@ console.log("\ndecision surface");
dom.window.close();
}
// Runtime decisions expose direct actions, so their displayed number keys must
// invoke the same callbacks while preserving normal typing behavior.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
const selected: string[] = [];
let cancelled = 0;
await act(async () => {
root.render(
<LocaleProvider>
<RuntimeDecisionCard
id="runtime-shortcuts"
title="Choose runtime action"
badge="Runtime"
meta="Select an action"
actions={["1", "2", "3"].map((key) => ({
key,
label: `Action ${key}`,
description: `Run action ${key} across a deliberately long runtime boundary that must wrap without overlapping the next decision row.`,
danger: key === "3",
onClick: () => selected.push(key),
}))}
onCancel={() => { cancelled += 1; }}
/>
</LocaleProvider>,
);
await flushTimers();
});
const runtimeActions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
const runtimeAction = document.querySelector(".prompt-action") as HTMLElement | null;
const runtimeActionKey = runtimeAction?.querySelector(".prompt-action__key") as HTMLElement | null;
const runtimeActionLabel = runtimeAction?.querySelector(".prompt-action__label") as HTMLElement | null;
const runtimeActionDescription = runtimeAction?.querySelector(".prompt-action__desc") as HTMLElement | null;
if (!runtimeActions || !runtimeAction || !runtimeActionKey || !runtimeActionLabel || !runtimeActionDescription) {
throw new Error("runtime decision layout did not render");
}
const runtimeActionsStyle = window.getComputedStyle(runtimeActions);
eq(runtimeActionsStyle.gridAutoRows, "max-content", "all decision rows use their full content height");
eq(runtimeActionsStyle.alignContent, "start", "all decision rows stay aligned at the top of the scroll region");
const runtimeActionStyle = window.getComputedStyle(runtimeAction);
eq(runtimeActionStyle.height, "auto", "shared decision rows are not fixed-height");
eq(runtimeActionStyle.minHeight, "40px", "shared decision rows retain the compact product minimum");
eq(runtimeActionStyle.alignItems, "flex-start", "shared decision copy aligns from the top");
eq(window.getComputedStyle(runtimeActionKey).marginTop, "1px", "decision keys align visually with the label cap height");
eq(window.getComputedStyle(runtimeActionLabel).fontWeight, "620", "decision labels keep a clear visual hierarchy");
const runtimeDescriptionStyle = window.getComputedStyle(runtimeActionDescription);
eq(runtimeDescriptionStyle.whiteSpace, "normal", "shared decision descriptions can wrap");
eq(runtimeDescriptionStyle.display, "-webkit-box", "supplementary runtime copy uses the shared three-line clamp");
eq(runtimeDescriptionStyle.overflow, "hidden", "collapsed runtime descriptions stay inside their row");
eq(runtimeDescriptionStyle.overflowWrap, "anywhere", "unspaced decision descriptions stay inside their row");
eq(runtimeDescriptionStyle.lineHeight, "1.4", "wrapped decision descriptions keep readable density");
const runtimeDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
if (!runtimeDescriptionToggle) throw new Error("runtime description disclosure did not render");
eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "false", "runtime description starts collapsed");
await act(async () => {
runtimeDescriptionToggle.click();
await flushTimers();
});
eq(selected.length, 0, "expanding runtime details never triggers the decision action");
eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "true", "runtime description expansion is announced");
eq(window.getComputedStyle(runtimeActionDescription).overflow, "visible", "expanded runtime description reveals the full copy");
const runtimeActionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
const dangerousRuntimeDescription = runtimeActionButtons[2]?.querySelector(".prompt-action__desc") as HTMLElement | null;
if (!dangerousRuntimeDescription) throw new Error("dangerous runtime description did not render");
ok(!runtimeActionButtons[2].classList.contains("prompt-action--description-collapsible"), "dangerous runtime consequences are never clamped");
eq(window.getComputedStyle(dangerousRuntimeDescription).overflow, "visible", "dangerous runtime consequences stay fully visible");
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision number keys invoke their displayed actions");
await act(async () => {
root.render(
<LocaleProvider>
<RuntimeDecisionCard
id="runtime-shortcuts"
title="Choose runtime action"
badge="Runtime"
meta="Select an action"
actions={[{
key: "2", label: "Unavailable action", description: "Cannot run yet",
disabled: true, onClick: () => selected.push("disabled"),
}]}
onCancel={() => { cancelled += 1; }}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision shortcuts do not invoke disabled actions");
const input = document.createElement("input");
document.body.append(input);
await act(async () => {
input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision shortcuts ignore editable fields");
eq(cancelled, 1, "Escape cancels the runtime decision");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Clear context: default cancel; clear requires explicit confirm; Escape cancels.
{
const dom = installDom();
@@ -637,6 +839,34 @@ console.log("\ndecision surface");
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
ok(actions[0].classList.contains("prompt-action--selected"), "clear context defaults to cancel");
const clearDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
if (!clearDescriptionToggle) throw new Error("clear-context description disclosure did not render");
await act(async () => {
clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(cancelled, 0, "Enter on clear-context disclosure does not trigger the safe default");
eq(confirmed, 0, "Enter on clear-context disclosure never clears the conversation");
eq(clearDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands clear-context copy");
await act(async () => {
clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(clearDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses clear-context copy again");
await act(async () => {
clearDescriptionToggle.click();
await flushTimers();
});
eq(cancelled, 0, "expanding clear-context copy does not trigger the safe default");
eq(confirmed, 0, "expanding clear-context copy does not clear anything");
await act(async () => {
actions[1].click();
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { formatTokens, formatOptionalTokens } from "../lib/format";
describe("formatTokens", () => {
it("returns '-' for undefined, zero, and negative values", () => {
assert.equal(formatTokens(undefined), "-");
assert.equal(formatTokens(0), "-");
assert.equal(formatTokens(-100), "-");
});
it("formats values below threshold as plain numbers", () => {
assert.equal(formatTokens(1), "1");
assert.equal(formatTokens(500), "500");
assert.equal(formatTokens(999), "999");
});
it("abbreviates thousands with K suffix", () => {
assert.equal(formatTokens(1000), "1K");
assert.equal(formatTokens(1500), "1.5K");
assert.equal(formatTokens(142000), "142K");
assert.equal(formatTokens(999999), "1000K");
});
it("abbreviates millions with M suffix", () => {
assert.equal(formatTokens(1_000_000), "1M");
assert.equal(formatTokens(1_500_000), "1.5M");
assert.equal(formatTokens(25_000_000), "25M");
});
it("strips trailing .0 for exact multiples", () => {
assert.equal(formatTokens(1000), "1K");
assert.equal(formatTokens(2000), "2K");
assert.equal(formatTokens(1_000_000), "1M");
});
it("keeps one decimal place when needed", () => {
assert.equal(formatTokens(1500), "1.5K");
assert.equal(formatTokens(234000), "234K");
assert.equal(formatTokens(1_230_000), "1.2M");
});
it("respects custom decimals option", () => {
assert.equal(formatTokens(1500, { decimals: 2 }), "1.5K");
assert.equal(formatTokens(1555, { decimals: 2 }), "1.55K");
assert.equal(formatTokens(1_555_000, { decimals: 2 }), "1.55M");
});
it("respects custom threshold option", () => {
assert.equal(formatTokens(500, { threshold: 100 }), "0.5K");
assert.equal(formatTokens(99, { threshold: 100 }), "99");
assert.equal(formatTokens(100, { threshold: 100 }), "0.1K");
});
it("returns locale-formatted string when compact is false", () => {
const result = formatTokens(142000, { compact: false });
// toLocaleString output varies by locale, but should contain digits
assert.match(result, /142/);
assert.doesNotMatch(result, /[KM]/);
});
});
describe("formatOptionalTokens", () => {
it("returns '-' for missing, zero, and negative values", () => {
assert.equal(formatOptionalTokens(undefined), "-");
assert.equal(formatOptionalTokens(null), "-");
assert.equal(formatOptionalTokens(0), "-");
assert.equal(formatOptionalTokens(-50), "-");
});
it("formats positive values identically to formatTokens", () => {
assert.equal(formatOptionalTokens(1500), "1.5K");
assert.equal(formatOptionalTokens(1_000_000), "1M");
assert.equal(formatOptionalTokens(500), "500");
});
});
@@ -0,0 +1,139 @@
// Run: tsx src/__tests__/local-path-click-e2e.test.tsx
//
// Headless end-to-end of the click-to-open chain from issue #7426: chat
// markdown with a plain Windows path is rendered with the real production
// pipeline (remarkLocalPathLinks + urlTransform + RichMarkdownLink), the
// anchor is clicked in a JSDOM document, and the native OpenLocalPath binding
// must receive the decoded local path. A plain http link must instead go to
// the system browser. No UI is involved — the Wails bridge is stubbed via
// window.go.main.App / window.runtime.
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "https://reasonix.local/" });
const { window } = dom;
// Set globals BEFORE dynamically importing React DOM so the renderer sees a
// real document (ESM imports are hoisted, hence dynamic import below).
(globalThis as Record<string, unknown>).window = window;
(globalThis as Record<string, unknown>).document = window.document;
(globalThis as Record<string, unknown>).HTMLElement = window.HTMLElement;
(globalThis as Record<string, unknown>).MouseEvent = window.MouseEvent;
// React 19 requires this flag for act() to flush renders/pass-through events.
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
// Bridge spies: OpenLocalPath is what the click must call; BrowserOpenURL is
// what plain http links must call instead.
const opened: string[] = [];
const browsed: string[] = [];
(window as unknown as Record<string, unknown>).go = {
main: {
App: {
OpenLocalPath: async (path: string) => {
opened.push(path);
},
},
},
};
(window as unknown as Record<string, unknown>).runtime = {
BrowserOpenURL: (url: string) => {
browsed.push(url);
},
};
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;
}
}
const { createElement } = await import("react");
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { default: ReactMarkdown, defaultUrlTransform } = await import("react-markdown");
const { default: remarkGfm } = await import("remark-gfm");
const { remarkLocalPathLinks } = await import("../lib/localPathLinks");
const { RichMarkdownLink } = await import("../components/githubLink");
const markdownUrlTransform = (value: string) =>
value.startsWith("file:///") ? value : defaultUrlTransform(value);
const components = { a: RichMarkdownLink };
async function renderClick(markdown: string): Promise<HTMLAnchorElement[]> {
const container = window.document.createElement("div");
window.document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
createElement(
ReactMarkdown,
{ remarkPlugins: [remarkGfm, remarkLocalPathLinks], urlTransform: markdownUrlTransform, components },
markdown,
),
);
});
return Array.from(container.querySelectorAll("a"));
}
console.log("\nheadless click-to-open e2e");
// 1. Issue #7426 scenario: plain Windows path in chat text, CJK dirs.
{
const anchors = await renderClick("文件在 D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md 已生成");
ok(anchors.length === 1, "exactly one anchor rendered");
const href = anchors[0]?.getAttribute("href") ?? "";
ok(href === "file:///D:/Project/Jhtj/20250804_000000_001_%E4%B8%AD%E5%81%9C%E6%97%B6%E5%88%86%E6%9E%90/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md",
`anchor href is the encoded file URL (${href})`);
await act(async () => {
anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
});
ok(opened.length === 1, "click invoked OpenLocalPath once");
ok(opened[0] === "D:/Project/Jhtj/20250804_000000_001_中停时分析/05-静态验收.md",
`OpenLocalPath received the decoded CJK path (${opened[0]})`);
ok(browsed.length === 0, "system browser was not involved");
}
// 2. UNC path (markdown folds \\ into \, linkify restores it; the href uses
// forward slashes, which Windows accepts as a UNC form).
{
const anchors = await renderClick("共享盘 \\\\nas\\share\\docs\\report.md 已生成");
await act(async () => {
anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
});
ok(opened[1] === "//nas/share/docs/report.md", `UNC path forwarded as slash form (${opened[1]})`);
}
// 3. Explicit markdown link to a local file keeps working through the same path.
{
const anchors = await renderClick("[验收单](file:///D:/docs/acceptance.md)");
await act(async () => {
anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
});
ok(opened[2] === "D:/docs/acceptance.md", "explicit file:/// markdown link opens locally");
}
// 4. Plain http link must NOT hit OpenLocalPath.
{
const anchors = await renderClick("见 https://example.com/page 文档");
await act(async () => {
anchors[0].dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
});
ok(browsed.length === 1 && browsed[0] === "https://example.com/page", "http link went to the system browser");
ok(opened.length === 3, "OpenLocalPath was not called for the http link");
}
// 5. Non-path text renders no anchors and no clicks.
{
const anchors = await renderClick("版本 1.2.3 已发布");
ok(anchors.length === 0, "no anchors for plain text");
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,153 @@
// Run: tsx src/__tests__/local-path-links.test.tsx
//
// Recognition matrix for chat markdown local-path linkification (issue
// #7426): Windows drive paths, UNC paths and file:/// URLs become clickable
// links; sentence punctuation and non-path lookalikes must not be captured.
// Also covers the file:/// href round-trip used by RichMarkdownLink.
import { linkifyLocalPaths, localPathHref, remarkLocalPathLinks } from "../lib/localPathLinks";
import { localPathFromHref } from "../components/githubLink";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(
` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`,
);
failed += 1;
}
}
function ok(value: unknown, label: string) {
eq(Boolean(value), true, label);
}
function firstPath(text: string): string | undefined {
return linkifyLocalPaths(text).find((s) => s.path !== undefined)?.path;
}
function pathCount(text: string): number {
return linkifyLocalPaths(text).filter((s) => s.path !== undefined).length;
}
console.log("\nlinkifyLocalPaths — drive paths");
eq(firstPath("文件在 D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md 已生成"),
"D:\\Project\\Jhtj\\20250804_000000_001_中停时分析\\05-静态验收.md",
"issue case: Chinese dirs and underscores survive");
eq(firstPath("see D:\\a\\b c.md note"), "D:\\a\\b", "unescaped space still ends the path");
eq(firstPath("see D:\\a\\b\\ c.md"), "D:\\a\\b c.md", "escaped space unescapes to the real path");
eq(firstPath("saved C:/Users/me/AppData/Local/x.md here"), "C:/Users/me/AppData/Local/x.md",
"forward-slash drive path");
eq(firstPath("see D:\\x\\y.md. done"), "D:\\x\\y.md", "trailing period stripped");
eq(firstPath("path D:\\x\\y.md, ok"), "D:\\x\\y.md", "trailing comma stripped");
eq(firstPath("see D:\\x\\y.md) done"), "D:\\x\\y.md", "trailing closing paren stripped");
eq(firstPath("see D:\\x\\y.md... done"), "D:\\x\\y.md", "trailing ellipsis stripped");
eq(firstPath("see D:\\x\\y.md。打开它"), "D:\\x\\y.md", "CJK period stops the path");
eq(firstPath("见 D:\\x\\y.md,查看详情"), "D:\\x\\y.md", "CJK comma stops the path");
eq(firstPath("打开 D:\\x\\y.md; 完成"), "D:\\x\\y.md", "semicolon stops the path");
eq(firstPath("见 D:\\x\\y.md(已生成)"), "D:\\x\\y.md", "full-width parens stop the path");
eq(firstPath("报告 D:\\x\\y.md(终版)完成"), "D:\\x\\y.md", "full-width paren group after path");
console.log("\nlinkifyLocalPaths — file:/// and UNC");
eq(firstPath("see file:///D:/Project/report/05-final.md"), "file:///D:/Project/report/05-final.md",
"file URL matched whole, drive part not double-matched");
eq(firstPath("见 file:///D:/Project/中停时分析/05-静态验收.md"), "file:///D:/Project/中停时分析/05-静态验收.md",
"file URL with CJK dirs");
eq(firstPath("see file:///D:/x/y.md. done"), "file:///D:/x/y.md", "file URL trailing period stripped");
eq(firstPath("see \\nas\\share\\docs\\report.md done"), "\\\\nas\\share\\docs\\report.md",
"UNC path matched whole and \\\\ prefix restored");
console.log("\nlinkifyLocalPaths — lookalikes and non-paths");
eq(pathCount("C: drive"), 0, "bare drive letter is not a path");
eq(pathCount("at 10:30 now"), 0, "clock time is not a path");
eq(pathCount("version 1.2.3 released"), 0, "version string is not a path");
eq(pathCount("http://example.com 正常"), 0, "http URL untouched (GFM handles it)");
eq(pathCount("a\\b\\c.md 讨论"), 0, "share-less backslash path in prose is not a UNC path");
eq(firstPath("见 \\nas\\share\\x.md"), "\\\\nas\\share\\x.md", "real UNC path still matches");
eq(firstPath("见 C:\\Program\\ Files\\ (x86) 完成"), "C:\\Program Files (x86)", "escaped-space path with (x86) keeps its closing paren");
console.log("\nsegment alternation");
const segs = linkifyLocalPaths("见 D:\\x\\y.md 然后 file:///C:/a/b.txt 完成");
eq(segs.length, 5, "text/link/text/link/text alternation");
eq(segs[0], { text: "见 " }, "leading plain text");
eq(segs[1].path, "D:\\x\\y.md", "first link segment");
eq(segs[2], { text: " 然后 " }, "middle plain text");
eq(segs[3].path, "file:///C:/a/b.txt", "second link segment");
eq(segs[4], { text: " 完成" }, "trailing plain text");
console.log("\nlocalPathHref round-trip");
eq(localPathHref("D:\\x\\y.md"), "file:///D:/x/y.md", "backslashes become forward slashes");
eq(localPathHref("D:\\Project\\中停时分析\\05-静态验收.md"), "file:///D:/Project/%E4%B8%AD%E5%81%9C%E6%97%B6%E5%88%86%E6%9E%90/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md",
"CJK percent-encoded");
eq(localPathHref("D:\\a\\b#c.md"), "file:///D:/a/b%23c.md", "hash escaped for URL safety");
eq(localPathHref("file:///C:/a/b.txt"), "file:///C:/a/b.txt", "already-absolute file URL is not double-prefixed");
eq(localPathHref("file:///D:/a%20b.txt"), "file:///D:/a%20b.txt", "literal %xx in a file URL is not double-encoded");
eq(localPathFromHref("file:///D:/a%20b.txt"), "D:/a b.txt", "decoded %20 becomes a real space");
eq(localPathFromHref("file:///D:/x/y.md"), "D:/x/y.md", "decodes plain path");
eq(localPathFromHref("file:///D:/Project/%E4%B8%AD%E5%81%9C%E6%97%B6%E5%88%86%E6%9E%90/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md"),
"D:/Project/中停时分析/05-静态验收.md", "decodes percent-encoded CJK");
eq(localPathFromHref("file:///D:/x/%zz"), null, "malformed escapes yield null");
eq(localPathFromHref("https://example.com/x"), null, "http URL is not local");
eq(localPathFromHref(undefined), null, "undefined href is not local");
console.log("\nremark plugin via react-markdown render");
import React from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { renderToStaticMarkup } from "react-dom/server";
// Mirrors MarkdownRenderer: file:/// anchors survive the default sanitizer.
const markdownUrlTransform = (value: string) =>
value.startsWith("file:///") ? value : defaultUrlTransform(value);
const html = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
{"文件在 D:\\Project\\x\\05-静态验收.md 已生成"}
</ReactMarkdown>,
);
ok(html.includes('href="file:///D:/Project/x/05-%E9%9D%99%E6%80%81%E9%AA%8C%E6%94%B6.md"'),
"plain drive path renders as a file:/// anchor");
ok(html.includes("05-静态验收.md"), "anchor label keeps the original text");
const mixed = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
{"见 D:\\x\\y.md,然后 file:///C:/a/b.txt 完成"}
</ReactMarkdown>,
);
ok(mixed.includes('href="file:///D:/x/y.md"'), "CJK comma boundary renders drive link");
ok(mixed.includes('href="file:///C:/a/b.txt"'), "explicit file URL renders its own anchor");
const plain = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
{"no paths here"}
</ReactMarkdown>,
);
ok(!plain.includes("<a"), "plain text without paths renders no anchors");
const nested = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
{"[D:\\x\\y.md](https://example.com)"}
</ReactMarkdown>,
);
ok(!nested.includes("file:///"), "explicit link text is not re-linkified (no nested anchors)");
const uppercase = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkLocalPathLinks]} urlTransform={markdownUrlTransform}>
{"see FILE:///C:/x/y.md"}
</ReactMarkdown>,
);
ok(!uppercase.includes("file:///"), "uppercase FILE:/// stays inert (case-sensitive allowlist)");
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -3,7 +3,7 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { ModelSwitcher } from "../components/ModelSwitcher";
import { ModelSwitcher, normalizeModelInfo } from "../components/ModelSwitcher";
import { LocaleProvider } from "../lib/i18n";
import type { ModelInfo } from "../lib/types";
@@ -53,6 +53,15 @@ let calls = 0;
const picked: string[] = [];
const pickGates: Array<ReturnType<typeof deferred<boolean>>> = [];
let catalogLoader: (() => Promise<ModelInfo[]>) | undefined;
const normalizedNullMetadata = normalizeModelInfo({
ref: "provider-a/model-a",
provider: null,
model: null,
current: true,
} as unknown as ModelInfo);
if (normalizedNullMetadata.provider !== "" || normalizedNullMetadata.model !== "") {
throw new Error(`null model metadata was not normalized: ${JSON.stringify(normalizedNullMetadata)}`);
}
let currentCatalog: ModelInfo[] = [
{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
];
@@ -312,5 +321,24 @@ await act(async () => {
await newerPickGate.promise;
});
// Legacy or malformed settings can surface null display metadata despite the
// bridge contract. Catalog loading must normalize it before grouping/search.
currentCatalog = [
{ ref: "provider-a/model-a", provider: null, model: null, current: true } as unknown as ModelInfo,
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
];
await act(async () => {
root.render(renderSwitcher("model-a", "tab-null-metadata"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const nullSafeOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']"));
if (nullSafeOptions.length !== 2) {
throw new Error(`null model metadata prevented catalog rendering: ${nullSafeOptions.length}`);
}
await act(async () => root.unmount());
console.log("model switcher refresh: PASS");
@@ -343,6 +343,8 @@ async function waitFor(label: string, predicate: () => boolean) {
throw new Error(`timed out waiting for ${label}`);
}
let projectedRuntimeEpoch = "runtime-local";
function tabMeta(): TabMeta {
return {
id: "tab-a",
@@ -355,6 +357,7 @@ function tabMeta(): TabMeta {
sessionPath: "/repo/sessions/tab-a.jsonl",
label: "model",
ready: true,
runtime: { phase: "ready", epoch: projectedRuntimeEpoch },
running: false,
cancellable: false,
mode: "normal",
@@ -369,6 +372,7 @@ function metaForTab(): Meta {
return {
label: "model",
ready: true,
runtime: { phase: "ready", epoch: projectedRuntimeEpoch },
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
@@ -387,7 +391,7 @@ function metaForTab(): Meta {
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effortInfo: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const eventHandlers: Array<(e: WireEvent) => void> = [];
const rebuiltHandlers: Array<(tabId?: string) => void> = [];
const rebuiltHandlers: Array<(tabId?: string, runtimeEpoch?: string) => void> = [];
let holdNextListTabs: Promise<void> | undefined;
let modeDrain: ReturnType<typeof deferred<string[]>> | undefined;
let toolApprovalModeDrain: ReturnType<typeof deferred<string[]>> | undefined;
@@ -398,7 +402,7 @@ let rejectNextComposerProfile = false;
window.runtime = {
EventsOn: (name: string, cb: (payload: unknown) => void) => {
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string) => void);
if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string, runtimeEpoch?: string) => void);
return () => {};
},
BrowserOpenURL: () => {},
@@ -461,6 +465,34 @@ await act(async () => {
await flushPromises();
});
// A Local tab already has an accepted epoch before Workbench projects a
// Remote runtime onto the same surface. The Remote transition must publish its
// authority before tagged Host events arrive, or the frontend rejects them all
// as stale Local traffic.
const remoteEpochApproval = {
kind: "approval_request",
tabId: "tab-a",
runtimeEpoch: "runtime-remote",
approval: { id: "remote-epoch-1", tool: "bash", subject: "Remote epoch prompt" },
} as WireEvent;
await act(async () => {
for (const handler of eventHandlers) handler(remoteEpochApproval);
await flushPromises();
});
eq(controller?.state.approval, undefined, "a Remote event is fenced while the Local epoch is still authoritative");
await act(async () => {
projectedRuntimeEpoch = "runtime-remote";
for (const handler of rebuiltHandlers) handler("tab-a", "runtime-remote");
for (const handler of eventHandlers) handler(remoteEpochApproval);
await flushPromises();
});
eq(controller?.state.approval?.id, "remote-epoch-1", "the projected Remote epoch admits tagged Host events");
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a", runtimeEpoch: "runtime-remote" } as WireEvent);
await flushPromises();
});
eq(controller?.state.approval, undefined, "the Remote epoch regression fixture resets cleanly");
// A reconciliation fetch starts (its snapshot time is captured now), then the
// backend attach replays the pending plan approval before the fetch resolves.
const gate = deferred<void>();
@@ -623,13 +655,23 @@ eq(controller?.state.running, false, "fresh idle snapshot releases the blocked s
eq(controller?.state.approval?.subject, "new composer-profile prompt", "a late atomic profile drain cannot dismiss a new same-id prompt");
composerProfileDrain = undefined;
const rebuiltProfileCallsBefore = composerProfileCalls;
const falseRebuildProfileCallsBefore = composerProfileCalls;
await act(async () => {
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await flushPromises();
});
eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per rebuilt controller generation");
eq(composerProfileCalls, falseRebuildProfileCallsBefore, "a rebuild notice without a new runtime identity does not replay the same profile");
const rebuiltProfileCallsBefore = composerProfileCalls;
await act(async () => {
projectedRuntimeEpoch = "runtime-next";
for (const handler of rebuiltHandlers) handler("tab-a", "runtime-next");
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await flushPromises();
});
eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per actual runtime generation");
const concurrentProfileDrain = deferred<string[]>();
composerProfileDrain = concurrentProfileDrain;
@@ -0,0 +1,188 @@
// Run: tsx src/__tests__/provider-editor-model-picker.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { LocaleProvider } from "../lib/i18n";
import { ProviderEditor, ProviderEditorModelPicker, providerSupportsServerWebSearch } from "../components/SettingsPanel";
import type { ProviderView } 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 flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
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.sessionStorage = dom.window.sessionStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
window.scrollTo = () => {};
function renderPicker(candidates: string[], visionCapability: "configurable" | "unsupported" = "configurable") {
return (
<LocaleProvider>
<ProviderEditorModelPicker
candidates={candidates}
selectedModels={[]}
visionModels={[]}
visionCapability={visionCapability}
contextWindows={{}}
disabled={false}
onToggleModel={() => undefined}
onToggleVision={() => undefined}
onContextWindowChange={() => undefined}
onSelectAll={() => undefined}
onClear={() => undefined}
/>
</LocaleProvider>
);
}
console.log("\nprovider editor model picker");
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let threw = false;
try {
await act(async () => {
root.render(renderPicker([]));
await flushPromises();
});
await act(async () => {
root.render(renderPicker(["zen-v1", "zen-v1-pro"]));
await flushPromises();
});
} catch (error) {
threw = true;
process.stdout.write(` ERROR ${String(error)}\n`);
}
ok(!threw, "model picker can render after async model fetch returns candidates");
ok(rootEl.textContent?.includes("zen-v1") === true, "model picker shows fetched custom provider models");
await act(async () => {
root.render(renderPicker(["deepseek-v4-flash"], "unsupported"));
await flushPromises();
});
ok(rootEl.textContent?.includes("No image input") === true, "known text-only DeepSeek models show a read-only image capability");
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "text-only model card does not render a second image checkbox");
ok(providerSupportsServerWebSearch("responses", "https://api.deepseek.com"), "DeepSeek Responses exposes server-side web search");
ok(providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com/anthropic"), "DeepSeek Anthropic exposes server-side web search");
ok(!providerSupportsServerWebSearch("openai", "https://api.deepseek.com"), "DeepSeek Chat Completions does not expose server-side web search");
ok(!providerSupportsServerWebSearch("responses", "https://relay.deepseek.com"), "DeepSeek-like subdomains do not inherit official defaults");
ok(!providerSupportsServerWebSearch("responses", "https://api.deepseek.com/anthropic"), "Responses rejects the Anthropic base path");
ok(!providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com"), "Anthropic requires its documented base path");
ok(!providerSupportsServerWebSearch("responses", "http://api.deepseek.com"), "insecure DeepSeek URLs do not inherit official defaults");
const builtInProvider: ProviderView = {
name: "deepseek",
builtIn: true,
added: true,
kind: "openai",
baseUrl: "https://api.deepseek.com",
models: ["deepseek-chat"],
visionModels: [],
visionModelsConfigured: false,
modelsUrl: "",
default: "deepseek-chat",
apiKeyEnv: "",
keySet: false,
balanceUrl: "",
contextWindow: 128_000,
reasoningProtocol: "deepseek",
thinking: "",
supportedEfforts: [],
defaultEffort: "",
};
const deepSeekResponsesProvider: ProviderView = {
...builtInProvider,
name: "deepseek-responses",
builtIn: false,
kind: "responses",
models: ["deepseek-v4-flash"],
default: "deepseek-v4-flash",
webSearch: true,
};
function renderProviderEditor(initial?: ProviderView) {
return (
<LocaleProvider>
<ProviderEditor
initial={initial}
kinds={["openai"]}
busy={false}
onCancel={() => undefined}
onSave={() => undefined}
/>
</LocaleProvider>
);
}
let editorThrew = false;
try {
await act(async () => {
root.render(renderProviderEditor(builtInProvider));
await flushPromises();
});
await act(async () => {
root.render(renderProviderEditor());
await flushPromises();
});
} catch (error) {
editorThrew = true;
process.stdout.write(` ERROR ${String(error)}\n`);
}
ok(!editorThrew, "provider editor can switch from built-in to custom without changing hook order");
ok(rootEl.textContent?.includes("OpenAI-compatible") === true, "provider editor renders the custom provider fields after the switch");
await act(async () => {
root.render(<div />);
await flushPromises();
});
await act(async () => {
root.render(renderProviderEditor(deepSeekResponsesProvider));
await flushPromises();
});
const webSearchSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]');
ok(rootEl.textContent?.includes("Server-side web search") === true, "DeepSeek Responses editor separates service capabilities from model selection");
ok(webSearchSwitch?.checked === true, "curated DeepSeek Responses capability is enabled in the editor");
ok(rootEl.textContent?.includes("No image input") === true, "DeepSeek Responses editor labels image input as unsupported");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -39,8 +39,8 @@ ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery c
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");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText, code))"), "raw quiet notices are skipped before localization");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text, code))"), "localized quiet notices are skipped before rendering");
const removedPromptKeys = [
"recovery.open",
@@ -198,6 +198,8 @@ eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: v
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
const readinessUser = readinessState.items.find((it) => it.kind === "user");
eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "final readiness does not mark the delivered user message as failed");
eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, deliveryRecovery: true });
const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done" } as WireEvent });
@@ -12,10 +12,11 @@ import {
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
normalizeProviderView,
} from "../components/SettingsPanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { SettingsView } from "../lib/types";
import type { ProviderView, SettingsView } from "../lib/types";
import {
applyTypographyPreferences,
createDefaultTypographyPreferences,
@@ -83,7 +84,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
bot: {
enabled: false,
model: "",
@@ -133,6 +134,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
desktopLayoutStyle: "workbench",
desktopTheme: "auto",
desktopThemeStyle: "graphite",
desktopTerminalTheme: "auto",
closeBehavior: "background",
displayMode,
statusBarStyle: "text",
@@ -151,6 +153,20 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
console.log("\nsettings refresh snapshot");
const nullableProvider = normalizeProviderView({
name: null,
baseUrl: null,
} as unknown as ProviderView);
eq(nullableProvider.name, "", "provider snapshots normalize a null name at the settings boundary");
eq(nullableProvider.baseUrl, "", "provider snapshots normalize a null base URL at the settings boundary");
const glmProvider = normalizeProviderView({
name: "custom-glm",
baseUrl: "https://gateway.example.com/v1",
reasoningProtocol: "glm",
} as ProviderView);
eq(glmProvider.reasoningProtocol, "glm", "provider snapshots preserve the explicit GLM reasoning protocol");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
@@ -190,6 +206,11 @@ const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body>
pretendToBeVisual: true,
url: "http://localhost/",
});
// React's legacy input-event fallback expects these IE hooks when JSDOM does
// not expose native input event support. The custom threshold editor focuses
// its input on open, so keep that production behavior testable without noise.
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
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;
@@ -256,6 +277,9 @@ await act(async () => {
const compactButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Compact") as HTMLButtonElement | undefined;
if (!compactButton) throw new Error("compact display mode button did not render");
const generalFieldLabels = Array.from(rootEl.querySelectorAll(".settings-section__body > .settings-field > .settings-field__copy > .settings-field__label"))
.map((label) => label.textContent?.trim());
eq(generalFieldLabels[0], "Desktop style", "general settings place desktop style first");
eq(document.querySelectorAll(".step-limit-control").length, 0, "general settings hide executor and planner step-limit controls");
ok(!document.body.textContent?.includes("step limit"), "general settings keep automatic progress free of step-limit copy");
ok(!document.body.textContent?.includes("Automatic plan mode"), "general settings omit the retired automatic Plan Mode control");
@@ -274,6 +298,160 @@ await act(async () => {
root.unmount();
});
// Models > Agent runtime: the compaction preference is directly visible, shows
// the effective token threshold, and reloads the persisted Settings snapshot.
const compactRootEl = document.createElement("div");
document.body.appendChild(compactRootEl);
const compactRoot = createRoot(compactRootEl);
let compactSettings = baseSettings("standard");
delete compactSettings.agent.compactRatio; // Old backends omit the additive field.
compactSettings.agent.effectiveCompactRatio = 0.75;
compactSettings.agent.compactRatioOverridden = true;
compactSettings.agent.compactRatioRemote = true;
compactSettings.defaultModel = "context-provider/context-model";
compactSettings.providers = [{
name: "context-provider",
builtIn: false,
added: true,
kind: "openai",
baseUrl: "https://context.example.com/v1",
chatUrl: "",
models: ["context-model"],
visionModels: [],
visionModelsConfigured: false,
modelsUrl: "",
default: "context-model",
apiKeyEnv: "",
keySet: false,
requiresKey: false,
configured: true,
balanceUrl: "",
contextWindow: 100_000,
reasoningProtocol: "",
thinking: "",
supportedEfforts: [],
defaultEffort: "",
modelOverrides: [],
}];
let compactRatioCalls: number[] = [];
window.go = {
main: {
App: {
Settings: async () => compactSettings,
FetchAllProviderModels: async () => ({}),
SetCompactRatio: async (ratio: number) => {
compactRatioCalls.push(ratio);
compactSettings = { ...compactSettings, agent: { ...compactSettings.agent, compactRatio: ratio } };
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
compactRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
});
ok(compactRootEl.textContent?.includes("Advanced context management") === false, "compaction preference has no redundant advanced disclosure");
ok(compactRootEl.textContent?.includes("Automatic compaction threshold") === true, "compaction preference is visible without expanding a disclosure");
ok(compactRootEl.textContent?.includes("80,000 tokens") === true, "compact ratio shows the default model token threshold");
ok(compactRootEl.textContent?.includes("Current threshold: 80% · Balanced") === true, "compact ratio summarizes the saved preset separately");
ok(compactRootEl.textContent?.includes("effective threshold is 75%") === true, "project override shows the active effective threshold");
ok(compactRootEl.textContent?.includes("changes only the local default") === true, "remote workbench scope is explicit");
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "custom compact ratio editor stays hidden on the default path");
const balancedCompactButton = compactRootEl.querySelector('button[aria-label="80% · Balanced"]') as HTMLButtonElement | null;
if (!balancedCompactButton) throw new Error("balanced compaction preset did not render");
ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected");
const customCompactButton = Array.from(compactRootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Custom threshold…")) as HTMLButtonElement | undefined;
if (!customCompactButton) throw new Error("custom compaction threshold option did not render");
ok(customCompactButton.closest(".compact-ratio-presets") === null, "custom compaction is a separate disclosure rather than a preset value");
ok(customCompactButton.hasAttribute("aria-pressed") === false, "custom disclosure does not announce a saved selection state");
await act(async () => {
customCompactButton.click();
await flushPromises();
});
let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
if (!customCompactInput) throw new Error("custom compaction threshold input did not open");
eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent");
ok(compactRootEl.textContent?.includes("Tool output is trimmed at 60%") === true, "custom compact ratio explains the lower guard rail");
ok(compactRootEl.textContent?.includes("90% forces compaction") === true, "custom compact ratio explains the upper guard rail");
ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input");
ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state");
ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection");
const customCompactApply = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Apply") as HTMLButtonElement | undefined;
if (!customCompactApply) throw new Error("custom compaction threshold apply action did not render");
const inputValueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
const setCustomCompactInput = (input: HTMLInputElement, value: string) => {
const previous = input.value;
inputValueSetter?.call(input, value);
(input as HTMLInputElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
};
await act(async () => {
setCustomCompactInput(customCompactInput, "64");
await flushPromises();
});
ok(customCompactApply.disabled, "out-of-range custom compact ratio cannot be applied");
eq(compactRatioCalls.length, 0, "editing a custom compact ratio does not save eagerly");
await act(async () => {
setCustomCompactInput(customCompactInput, "75");
await flushPromises();
});
ok(!customCompactApply.disabled, "valid custom compact ratio enables explicit apply");
await act(async () => {
customCompactApply.click();
await flushPromises();
});
eq(compactRatioCalls.length, 1, "custom compact ratio mutation is invoked once after apply");
eq(compactRatioCalls[0], 0.75, "custom compact ratio converts percentage to fraction");
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "successful custom compact ratio apply collapses the editor");
ok(compactRootEl.textContent?.includes("Current threshold: 75% · Custom") === true, "saved custom compact ratio is summarized independently from the disclosure");
ok(customCompactButton.textContent?.includes("Custom threshold…") === true, "custom disclosure keeps an action label after saving");
await act(async () => {
customCompactButton.click();
await flushPromises();
});
customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
if (!customCompactInput) throw new Error("saved custom compaction threshold did not reopen");
await act(async () => {
setCustomCompactInput(customCompactInput, "74");
customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushPromises();
});
eq(compactRatioCalls.length, 1, "Escape cancels a custom compact ratio without saving");
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Escape collapses the custom compact ratio editor");
await act(async () => {
customCompactButton.click();
await flushPromises();
});
customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
if (!customCompactInput) throw new Error("custom compaction threshold did not reopen for cancel");
const customCompactCancel = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Cancel") as HTMLButtonElement | undefined;
if (!customCompactCancel) throw new Error("custom compaction threshold cancel action did not render");
await act(async () => {
customCompactCancel.click();
await flushPromises();
});
eq(compactRatioCalls.length, 1, "Cancel closes a custom compact ratio without saving");
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Cancel collapses the custom compact ratio editor");
const earlierCompactButton = compactRootEl.querySelector('button[aria-label="70% · Earlier"]') as HTMLButtonElement | null;
if (!earlierCompactButton) throw new Error("earlier compaction preset did not render");
await act(async () => {
earlierCompactButton.click();
await flushPromises();
});
eq(compactRatioCalls.length, 2, "compact ratio preset adds one mutation");
eq(compactRatioCalls[1], 0.7, "compact ratio preset sends the expected fraction");
ok(earlierCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload");
await act(async () => {
compactRoot.unmount();
});
const retryRootEl = document.createElement("div");
document.body.appendChild(retryRootEl);
const retryRoot = createRoot(retryRootEl);
@@ -0,0 +1,54 @@
// Run: tsx src/__tests__/settings-responsive-layout.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
function ruleBlock(source: string, selector: string): string {
const start = source.indexOf(`${selector} {`);
if (start < 0) return "";
const bodyStart = source.indexOf("{", start) + 1;
const end = source.indexOf("}", bodyStart);
return source.slice(bodyStart, end);
}
function declaration(block: string, property: string): string | undefined {
const match = block.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`));
return match?.[1].trim();
}
console.log("\nsettings responsive layout contract");
const wideTail = ruleBlock(styles, ".memory-tabs-row__tail");
eq(declaration(wideTail, "display"), "contents", "wide memory controls preserve the original row flex items");
eq(declaration(wideTail, "flex"), undefined, "wide memory wrapper does not compete in flex sizing");
const settingsResponsiveStart = styles.indexOf("/* ── responsive: compact content on narrow screens");
const narrowStart = styles.indexOf("@media (max-width: 900px)", settingsResponsiveStart);
const narrowEnd = styles.indexOf("@media (max-width: 760px)", narrowStart);
const narrowSettings = styles.slice(narrowStart, narrowEnd);
const narrowTail = ruleBlock(narrowSettings, ".memory-tabs-row__tail");
eq(declaration(narrowTail, "display"), "flex", "narrow memory controls form a flex group");
eq(declaration(narrowTail, "flex"), "0 1 100%", "narrow memory controls stay on their own row");
eq(declaration(narrowTail, "min-width"), "0", "narrow memory controls may shrink without overflowing");
eq(declaration(narrowTail, "gap"), "12px", "narrow memory controls retain their compact spacing");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -65,17 +65,40 @@ ok(
/case "deepseek-anthropic":\s*return t\("settings\.addProvider\.preset\.deepseekAnthropicDesc"\)/.test(settingsSource),
"DeepSeek Anthropic preset uses a localized description",
);
ok(
/case "token-rhythm":\s*return t\("settings\.addProvider\.preset\.tokenRhythmDesc"\)/.test(settingsSource) &&
/preset\.id === "token-rhythm"\) return t\("settings\.addProvider\.preset\.tokenRhythmLabel"\)/.test(settingsSource),
"Token Rhythm preset localizes the English and Chinese brand names",
);
ok(
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
source.includes('"settings.addProvider.preset.deepseekResponsesDesc"') &&
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"'),
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"') &&
source.includes('"settings.addProvider.preset.tokenRhythmLabel"') &&
source.includes('"settings.addProvider.preset.tokenRhythmDesc"'),
),
"DeepSeek protocol preset descriptions are present in every supported locale",
"provider preset localization is present in every supported locale",
);
ok(
enLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "Token Rhythm"') &&
zhLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "基元律动"') &&
zhTWLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "基元律动"'),
"Token Rhythm preset uses the official English and Chinese brand names",
);
ok(
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
source.includes('"settings.reasoningProtocol.glm"'),
),
"GLM reasoning protocol is localized in every supported locale",
);
ok(
/mockPreset\("deepseek-anthropic",\s*"DeepSeek Anthropic"/.test(bridgeSource),
"browser mock exposes the DeepSeek Anthropic preset",
);
ok(
/mockPreset\("token-rhythm",\s*"Token Rhythm"/.test(bridgeSource),
"browser mock exposes the Token Rhythm preset",
);
ok(
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -1;\s*if \(id === "deepseek-anthropic"\) return 0;/.test(bridgeSource),
"browser mock ranks Responses first and Anthropic as the adjacent compatibility preset",
@@ -1,6 +1,9 @@
// Run: tsx src/__tests__/statusbar-workspace.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { StatusBar } from "../components/StatusBar";
import { LocaleProvider } from "../lib/i18n";
@@ -121,7 +124,28 @@ console.log("\nstatus bar workspace");
{
const html = renderStatusBar({ items: ["model"] });
ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar hides the operational jobs entry while idle");
}
{
const html = renderStatusBar({
items: ["model"],
jobs: [{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }],
});
ok(html.includes("Background jobs"), "running background jobs remain visible outside configurable metrics");
ok(html.includes("1"), "background jobs entry exposes the running count");
}
{
const html = renderStatusBar({
items: ["model"],
backgroundRuntimes: [{
tabId: "running-1", title: "Detached delivery", detached: true,
running: true, pendingPrompt: false, jobs: [],
}],
});
ok(html.includes("Background jobs"), "a running detached task remains visible without child jobs");
ok(html.includes("<b>1</b>"), "a jobless active runtime contributes to the recovery count");
}
{
@@ -129,5 +153,170 @@ console.log("\nstatus bar workspace");
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
}
{
const estimated = renderStatusBar({
items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
context: { used: 0, window: 0, sessionTokens: 1_200, estimated: true },
usage: {
promptTokens: 800,
completionTokens: 200,
totalTokens: 1_000,
cacheHitTokens: 0,
cacheMissTokens: 800,
estimated: true,
},
sessionTokens: 1_200,
turnTokens: 1_000,
turnCost: 0.2,
cost: 0.3,
currency: "USD",
});
ok((estimated.match(/≈/g) ?? []).length === 4, "estimated token and cost metrics use an approximation marker");
const empty = renderStatusBar({
items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
context: { used: 0, window: 0, sessionTokens: 0, estimated: true },
usage: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
estimated: true,
},
currency: "USD",
});
ok(!empty.includes("≈-"), "empty estimated metrics remain a plain dash");
}
{
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;
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
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);
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }),
});
let stopped = "";
const rootEl = document.getElementById("root")!;
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
jobs={[{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }]}
onCancelJob={async (jobID) => { stopped = jobID; return true; }}
/>
</LocaleProvider>,
);
});
const jobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
await act(async () => { jobsButton?.click(); });
const stopButton = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
await act(async () => { stopButton?.click(); await Promise.resolve(); });
ok(stopped === "bash-1", "background jobs popover routes Stop to the selected job");
let routed = "";
let revealed = "";
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
backgroundRuntimes={[
{
tabId: "detached-1", title: "Detached delivery", detached: true,
running: false, pendingPrompt: false,
jobs: [{ id: "go-1", kind: "go", label: "go test", status: "running", startedAt: 1 }],
},
]}
onCancelRuntimeJob={async (tabID, jobID) => { routed = `${tabID}:${jobID}`; return true; }}
onRevealRuntime={async (tabID) => { revealed = tabID; }}
/>
</LocaleProvider>,
);
});
const globalJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
if (globalJobsButton?.getAttribute("aria-expanded") !== "true") {
await act(async () => { globalJobsButton?.click(); });
}
const globalStop = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
const openTask = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".jobs-popover__runtime-header button"))[0];
await act(async () => { globalStop?.click(); openTask?.click(); await Promise.resolve(); });
ok(routed === "detached-1:go-1", "global jobs route Stop to the owning detached task");
ok(revealed === "detached-1", "global jobs can reopen the exact detached task");
revealed = "";
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
backgroundRuntimes={[
{
tabId: "prompt-1", title: "Waiting delivery", detached: true,
running: false, pendingPrompt: true, jobs: [],
},
]}
onRevealRuntime={async (tabID) => { revealed = tabID; }}
/>
</LocaleProvider>,
);
});
const promptJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
if (promptJobsButton?.getAttribute("aria-expanded") !== "true") {
await act(async () => { promptJobsButton?.click(); });
}
ok(document.body.textContent?.includes("Waiting for input") === true, "pending-prompt runtime explains why it remains active");
const promptOpenTask = document.body.querySelector<HTMLButtonElement>(".jobs-popover__runtime-header button");
await act(async () => { promptOpenTask?.click(); await Promise.resolve(); });
ok(revealed === "prompt-1", "a pending-prompt runtime can be reopened without child jobs");
await act(async () => {
root.render(
<LocaleProvider>
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
workbenchTarget={{ kind: "ssh", hostId: "remote-1" }}
jobs={[{ id: "remote-job", kind: "bash", label: "remote build", status: "running", startedAt: 1 }]}
backgroundRuntimes={[
{
tabId: "local-1", title: "Local delivery", detached: true,
running: false, pendingPrompt: false,
jobs: [{ id: "local-job", kind: "go", label: "local test", status: "running", startedAt: 1 }],
},
]}
/>
</LocaleProvider>,
);
});
const mixedJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
ok(mixedJobsButton?.textContent?.includes("2") === true, "local and active Remote Workbench jobs share the total count");
if (mixedJobsButton?.getAttribute("aria-expanded") !== "true") {
await act(async () => { mixedJobsButton?.click(); });
}
ok(document.body.textContent?.includes("local test") === true, "local background jobs remain visible in Remote Workbench");
ok(document.body.textContent?.includes("remote build") === true, "active Remote Workbench jobs remain visible beside local jobs");
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,213 @@
// Run: tsx src/__tests__/terminal-theme.test.ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='terminal'></div></body></html>");
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
MutationObserver: dom.window.MutationObserver,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
});
Object.defineProperty(dom.window, "matchMedia", {
configurable: true,
value: () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
}),
});
const { applyTheme } = await import("../lib/theme");
const {
applyTerminalThemePreference,
createTerminalThemeSaveQueue,
getResolvedTerminalTheme,
normalizeTerminalThemePreference,
onTerminalThemePreferenceChange,
terminalThemeForElement,
} = await import("../lib/terminalTheme");
assert.equal(normalizeTerminalThemePreference("unknown"), "auto");
assert.equal(normalizeTerminalThemePreference(undefined), "auto", "older settings payloads fall back safely");
assert.equal(normalizeTerminalThemePreference("light"), "light");
applyTheme("light", "graphite");
applyTerminalThemePreference("auto");
assert.equal(getResolvedTerminalTheme(), "light", "follow-app resolves the current app theme");
assert.equal(document.documentElement.hasAttribute("data-terminal-theme"), false);
applyTerminalThemePreference("dark");
assert.equal(getResolvedTerminalTheme(), "dark", "explicit terminal theme overrides the app theme");
assert.equal(document.documentElement.getAttribute("data-terminal-theme"), "dark");
let notifications = 0;
const unsubscribe = onTerminalThemePreferenceChange(() => { notifications += 1; });
applyTerminalThemePreference("light");
unsubscribe();
assert.equal(notifications, 1, "open terminals are notified when the preference changes");
const host = document.getElementById("terminal")!;
host.style.setProperty("--terminal-bg", "#fafafa");
host.style.setProperty("--terminal-fg", "#202124");
host.style.setProperty("--terminal-cursor", "#9a4f00");
const xtermTheme = terminalThemeForElement(host);
assert.equal(xtermTheme.background, "#fafafa");
assert.equal(xtermTheme.foreground, "#202124");
assert.equal(xtermTheme.cursor, "#9a4f00");
assert.equal(xtermTheme.black, "#25272a", "light terminal uses a contrast-safe ANSI palette");
function relativeLuminance(hex: string): number {
assert.match(hex, /^#[0-9a-f]{6}$/i);
const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255);
const linear = channels.map((channel) => channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrastRatio(foreground: string, background: string): number {
const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)]
.sort((a, b) => b - a);
return (lighter + 0.05) / (darker + 0.05);
}
const ansiKeys = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue",
"brightMagenta", "brightCyan", "brightWhite",
] as const;
for (const key of ansiKeys) {
const color = xtermTheme[key];
assert.equal(typeof color, "string");
assert.ok(
contrastRatio(color!, xtermTheme.background!) >= 4.5,
`${key} must remain readable against the light terminal background`,
);
}
let releaseFirstSave!: () => void;
const firstSaveGate = new Promise<void>((resolve) => { releaseFirstSave = resolve; });
const saveOrder: string[] = [];
const saveTerminalTheme = createTerminalThemeSaveQueue(async (theme) => {
saveOrder.push(`start:${theme}`);
if (theme === "light") await firstSaveGate;
saveOrder.push(`end:${theme}`);
});
const firstSave = saveTerminalTheme("light");
await Promise.resolve();
const secondSave = saveTerminalTheme("dark");
await Promise.resolve();
assert.deepEqual(saveOrder, ["start:light"], "newer intent waits for the active Wails save");
releaseFirstSave();
await Promise.all([firstSave, secondSave]);
assert.deepEqual(saveOrder, ["start:light", "end:light", "start:dark", "end:dark"]);
const recoveryOrder: string[] = [];
const recoverAfterFailure = createTerminalThemeSaveQueue(async (theme) => {
recoveryOrder.push(theme);
if (theme === "light") throw new Error("simulated save failure");
});
const failedSave = recoverAfterFailure("light");
const recoveredSave = recoverAfterFailure("dark");
await assert.rejects(failedSave, /simulated save failure/);
await recoveredSave;
assert.deepEqual(recoveryOrder, ["light", "dark"], "a failed save does not poison newer intent");
const testDir = dirname(fileURLToPath(import.meta.url));
const terminalSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8");
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
function cssRuleDeclarations(css: string, selector: string): Record<string, string> {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
assert.ok(match, `missing CSS rule for ${selector}`);
return Object.fromEntries(
match[1]
.split(";")
.map((declaration) => declaration.trim())
.filter(Boolean)
.map((declaration) => {
const colon = declaration.indexOf(":");
assert.ok(colon > 0, `invalid declaration in ${selector}: ${declaration}`);
return [declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()];
}),
);
}
function assertCssRule(
css: string,
selector: string,
expected: Record<string, string>,
): void {
const actual = cssRuleDeclarations(css, selector);
for (const [property, value] of Object.entries(expected)) {
assert.equal(actual[property], value, `${selector} must set ${property} from terminal-owned tokens`);
}
}
const terminalStylesStart = stylesSource.indexOf("/* Integrated terminal */");
const terminalStylesEnd = stylesSource.indexOf("@media (max-width: 760px)", terminalStylesStart);
assert.ok(terminalStylesStart >= 0 && terminalStylesEnd > terminalStylesStart, "integrated terminal CSS section must exist");
const terminalStyles = stylesSource.slice(terminalStylesStart, terminalStylesEnd);
assert.ok(terminalSource.includes("terminal.options.theme = terminalThemeForElement(host)"));
assert.ok(!terminalSource.includes('background: "#111315"'));
assert.ok(settingsSource.includes("createTerminalThemeSaveQueue"));
assert.ok(settingsSource.includes("if (!terminalThemeSavePending.current)"));
assert.ok(settingsSource.includes("onTerminalTheme={setTerminalThemePreference}"));
assert.ok(stylesSource.includes(':root[data-terminal-theme="light"]'));
assert.ok(stylesSource.includes(':root[data-terminal-theme="dark"]'));
assert.doesNotMatch(terminalStyles, /var\(--(?:bg-elev-1|fg-muted)\)/, "auto mode must use defined app tokens");
assertCssRule(terminalStyles, ":root", {
"--terminal-surface": "var(--bg-elev)",
"--terminal-muted": "var(--fg-dim)",
"--terminal-cursor": "var(--accent)",
});
assertCssRule(terminalStyles, ':root[data-terminal-theme="dark"] .terminal-panel', { "color-scheme": "dark" });
assertCssRule(terminalStyles, ':root[data-terminal-theme="light"] .terminal-panel', { "color-scheme": "light" });
const terminalChromeMatrix: Array<[string, Record<string, string>]> = [
[".terminal-panel__header", {
"border-bottom": "1px solid var(--terminal-border)",
background: "var(--terminal-surface)",
}],
[".terminal-panel__identity span", { color: "var(--terminal-muted)" }],
[".terminal-shell-select", {
border: "1px solid var(--terminal-border)",
background: "var(--terminal-active)",
color: "var(--terminal-fg)",
}],
[".terminal-shell-select:hover, .terminal-shell-select:focus-visible", {
"border-color": "var(--terminal-cursor)",
}],
[".terminal-icon-button", { color: "var(--terminal-muted)" }],
[".terminal-icon-button:hover, .terminal-icon-button:focus-visible", {
"border-color": "var(--terminal-border)",
background: "var(--terminal-active)",
color: "var(--terminal-fg)",
}],
[".terminal-session--active", {
"border-bottom-color": "var(--terminal-cursor)",
background: "var(--terminal-active)",
}],
[".terminal-empty__spinner", { "border-top-color": "var(--terminal-cursor)" }],
];
for (const [selector, expected] of terminalChromeMatrix) {
assertCssRule(terminalStyles, selector, expected);
}
assertCssRule(stylesSource, ".layout--terminal-drawer-expanded .terminal-drawer", {
background: "var(--terminal-bg, #111315)",
"border-top": "1px solid var(--terminal-border)",
});
assertCssRule(stylesSource, ".layout--terminal-drawer-open .terminal-drawer .terminal-panel__header", {
"border-bottom": "1px solid var(--terminal-border)",
});
console.log("terminal theme tests passed");
@@ -30,7 +30,6 @@ const experience: ThemeExperienceView = {
baseStyle: "graphite",
effectiveStyle: "graphite",
activePack: null,
safeMode: false,
};
const savedPack: ThemePackView = {
+172 -33
View File
@@ -20,9 +20,16 @@ import {
setBaseAppearance,
themePackKind,
} from "../lib/themePack";
import { applyTheme, getThemeStyle } from "../lib/theme";
import { applyTheme, getThemeStyle, THEME_STYLES } from "../lib/theme";
import {
baseCodeReadabilityStylesheet,
codeReadabilityRatios,
contrastRatio,
deriveCreationCodeReadabilityPalette,
deriveCodeReadabilityPalette,
} from "../lib/codeReadability";
import { BASE_STYLE_PREVIEW_PALETTES, themePreviewPalette } from "../lib/themePreviewPalette";
import { themePreviewPaneAlpha } from "../components/ThemePreviewSurface";
import { themePreviewCodePalette, themePreviewPaneAlpha } from "../components/ThemePreviewSurface";
import {
activateThemePack,
applyExperienceToDOM,
@@ -38,6 +45,7 @@ const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const librarySource = readFileSync(resolve(testDir, "../components/ThemeLibrary.tsx"), "utf8");
const gallerySource = readFileSync(resolve(testDir, "../components/ThemeGallery.tsx"), "utf8");
const previewSurfaceSource = readFileSync(resolve(testDir, "../components/ThemePreviewSurface.tsx"), "utf8");
const confirmDialogSource = readFileSync(resolve(testDir, "../components/ConfirmDialog.tsx"), "utf8");
const overviewSource = readFileSync(resolve(testDir, "../components/AppearanceOverview.tsx"), "utf8");
const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8");
@@ -64,22 +72,49 @@ function ok(value: boolean, label: string) {
// Minimal DOM for applyThemePack
const attrs = new Map<string, string>();
const styleProps = new Map<string, string>();
const styleEl: {
type MockHead = {
appendChild: (el: MockStyleElement) => MockStyleElement;
removeChild: (el: MockStyleElement) => void;
};
type MockStyleElement = {
id: string;
textContent: string;
parentElement: { removeChild: (el: unknown) => void } | null;
parentElement: MockHead | null;
remove: () => void;
} = {
id: "",
textContent: "",
parentElement: null,
remove() {
const idx = headChildren.indexOf(styleEl);
};
const headChildren: MockStyleElement[] = [];
const mockHead: MockHead = {
appendChild(el: MockStyleElement) {
const existing = headChildren.indexOf(el);
if (existing >= 0) headChildren.splice(existing, 1);
headChildren.push(el);
el.parentElement = mockHead;
return el;
},
removeChild(el: MockStyleElement) {
const idx = headChildren.indexOf(el);
if (idx >= 0) headChildren.splice(idx, 1);
styleEl.textContent = "";
el.parentElement = null;
},
};
const headChildren: unknown[] = [];
function createMockStyleElement(): MockStyleElement {
const el: MockStyleElement = {
id: "",
textContent: "",
parentElement: null,
remove() {
mockHead.removeChild(el);
el.textContent = "";
},
};
return el;
}
function styleText(id: string): string {
return headChildren.find((el) => el.id === id)?.textContent || "";
}
(globalThis as unknown as { document: unknown }).document = {
documentElement: {
@@ -98,24 +133,12 @@ const headChildren: unknown[] = [];
},
},
},
head: {
appendChild(el: unknown) {
headChildren.push(el);
styleEl.parentElement = {
removeChild(child: unknown) {
const idx = headChildren.indexOf(child);
if (idx >= 0) headChildren.splice(idx, 1);
},
};
return el;
},
},
head: mockHead,
getElementById(id: string) {
if (id === "reasonix-theme-pack-overlay" && headChildren.includes(styleEl)) return styleEl;
return null;
return headChildren.find((el) => el.id === id) || null;
},
createElement(tag: string) {
if (tag === "style") return styleEl;
if (tag === "style") return createMockStyleElement();
return {};
},
querySelector() {
@@ -137,6 +160,88 @@ ok(!isSafeHex("url(x)"), "rejects url()");
ok(!isSafeHex("linear-gradient(red,blue)"), "rejects gradient");
ok(isThemeTokenKey("accent") && !isThemeTokenKey("hack"), "token whitelist");
const translucentCodePalette = deriveCodeReadabilityPalette("dark", "graphite", {
bg: "#102030",
bgSoft: "#ffffff33",
fg: "#f4f5f7",
borderSoft: "#ffffff22",
});
ok(/^#[0-9a-f]{6}$/.test(translucentCodePalette.background), "code background is flattened to an opaque color");
ok(translucentCodePalette.background === "#404d59", "transparent bgSoft is composited over the theme background");
ok(
Object.values(codeReadabilityRatios(translucentCodePalette)).every((ratio) => ratio >= 4.5),
"every code and diff text role reaches WCAG AA on its rendered background",
);
ok(
/^#[0-9a-f]{6}$/.test(translucentCodePalette.additionBackground) &&
/^#[0-9a-f]{6}$/.test(translucentCodePalette.deletionBackground),
"diff row backgrounds are pre-composited opaque colors",
);
ok(
contrastRatio(translucentCodePalette.addition, translucentCodePalette.additionBackground) >= 4.5 &&
contrastRatio(translucentCodePalette.deletion, translucentCodePalette.deletionBackground) >= 4.5,
"diff semantic text reaches WCAG AA on the final tinted row",
);
const adversarialMidtonePalette = deriveCodeReadabilityPalette("dark", "graphite", {
bg: "#3fcd1c",
bgSoft: "#cd1ce4",
fg: "#3fcd1c",
ok: "#15803d",
err: "#dc2626",
});
ok(
Object.values(codeReadabilityRatios(adversarialMidtonePalette)).every((ratio) => ratio >= 4.5),
"midtone custom themes keep every syntax role readable on plain and tinted diff rows",
);
let generatedPaletteMinimum = Number.POSITIVE_INFINITY;
for (let index = 0; index < 512; index += 1) {
const sample = ((index * 2654435761) >>> 0).toString(16).padStart(8, "0");
const palette = deriveCodeReadabilityPalette(index % 2 === 0 ? "dark" : "light", "graphite", {
bg: `#${sample.slice(0, 6)}`,
bgSoft: `#${sample.slice(2, 8)}`,
fg: `#${sample.slice(0, 6)}`,
ok: `#${sample.slice(1, 7)}`,
err: `#${sample.slice(2, 8)}`,
});
generatedPaletteMinimum = Math.min(generatedPaletteMinimum, ...Object.values(codeReadabilityRatios(palette)));
}
ok(generatedPaletteMinimum >= 4.5, "generated custom palettes preserve WCAG AA across every rendered code surface");
const invertedDarkPack = deriveCodeReadabilityPalette("dark", "graphite", { bg: "#ffffff", bgSoft: "#fafafa" });
ok(invertedDarkPack.string === "#0a3069", "syntax direction follows final code luminance instead of global dark mode");
const baseReadabilityCSS = baseCodeReadabilityStylesheet(THEME_STYLES);
for (const style of THEME_STYLES) {
for (const mode of ["dark", "light"] as const) {
const palette = deriveCodeReadabilityPalette(mode, style);
const basePack = draftPackView({
id: `base-${style}`,
name: style,
baseStyle: style,
tokens: {},
recipes: { density: "comfortable", corners: "soft" },
});
ok(
Object.values(codeReadabilityRatios(palette)).every((ratio) => ratio >= 4.5),
`${style} ${mode} base code palette reaches WCAG AA`,
);
ok(
JSON.stringify(themePreviewCodePalette(basePack, mode)) === JSON.stringify(palette),
`${style} ${mode} preview uses the live code palette`,
);
}
}
for (const mode of ["dark", "light"] as const) {
ok(
Object.values(codeReadabilityRatios(deriveCreationCodeReadabilityPalette(mode))).every((ratio) => ratio >= 4.5),
`Creation ${mode} code palette reaches WCAG AA`,
);
}
ok(
baseReadabilityCSS.includes(':root[data-theme-style="graphite"]') &&
baseReadabilityCSS.includes('.app--creation{--code-bg:'),
"base stylesheet installs complete root and Creation code palettes",
);
ok(isSafeBackgroundURL("/__reasonix_theme_asset/my-theme/abc/background.png"), "asset URL allowed");
ok(isSafeBackgroundURL("data:image/png;base64,aaa"), "data URL allowed");
ok(!isSafeBackgroundURL("https://evil.example/bg.png"), "remote URL rejected");
@@ -177,8 +282,10 @@ applyThemePack(draft);
ok(attrs.get("data-theme-pack") === "preview-pack", "sets data-theme-pack");
ok(attrs.get("data-theme-has-bg") === "true", "marks background present");
ok(styleProps.has("--theme-bg-image"), "sets background image var");
ok((styleEl as { textContent: string }).textContent.includes("--accent:#ff0000"), "injects dark accent override");
ok((styleEl as { textContent: string }).textContent.includes("--r:14px"), "applies round corners recipe");
ok(styleText("reasonix-theme-pack-overlay").includes("--accent:#ff0000"), "injects dark accent override");
ok(styleText("reasonix-theme-pack-overlay").includes("--code-bg:#101115"), "injects an opaque code readability island");
ok(styleText("reasonix-theme-pack-overlay").includes("--hl-comment:"), "injects contrast-checked syntax roles");
ok(styleText("reasonix-theme-pack-overlay").includes("--r:14px"), "applies round corners recipe");
const twoSceneDraft = draftPackView({
...draft,
@@ -190,6 +297,12 @@ ok(styleProps.get("--theme-bg-task-image")?.includes("background-task.png") ===
ok(styleProps.get("--theme-bg-task-opacity") === "0.35", "sets independent task opacity");
ok(styleProps.get("--theme-pane-card-pct") === "76%", "computes home card pane opacity");
ok(styleProps.get("--theme-pane-task-card-pct") === "82%", "computes task card pane opacity");
ok(styleProps.get("--theme-pane-session-hover-pct") === "76%", "computes home session-hover opacity tier");
ok(styleProps.get("--theme-pane-child-pct") === "80%", "computes home child opacity tier");
ok(styleProps.get("--theme-pane-interact-pct") === "90%", "computes home interaction opacity tier");
ok(styleProps.get("--theme-pane-task-session-hover-pct") === "94%", "computes task session-hover opacity tier");
ok(styleProps.get("--theme-pane-task-child-pct") === "98%", "computes task child opacity tier");
ok(styleProps.get("--theme-pane-task-interact-pct") === "100%", "caps task interaction opacity tier");
ok(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
// Older shells and partial mocks can expose the independent task scene without
@@ -211,6 +324,18 @@ ok(attrs.get("data-theme-scene") === "home", "scene home on root");
// Preview cancel restores previous (null) pack
clearThemePack();
ok(
[
"--theme-pane-session-hover-pct",
"--theme-pane-child-pct",
"--theme-pane-interact-pct",
"--theme-pane-task-session-hover-pct",
"--theme-pane-task-child-pct",
"--theme-pane-task-interact-pct",
].every((property) => !styleProps.has(property)),
"clearing a pack removes every extended pane opacity tier",
);
ok(styleText("reasonix-base-code-readability").includes("--code-add-bg:"), "applyTheme installs the base code readability stylesheet");
beginThemePreview(draft);
ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
cancelThemePreview();
@@ -298,7 +423,6 @@ const activeAuroraExperience = {
effectiveStyle: "aurora" as const,
activeThemeId: aurora.id,
activePack: aurora,
safeMode: false,
};
applyExperienceToDOM(activeAuroraExperience);
ok(configuredBaseStyleForSync(activeAuroraExperience) === null, "active pack effective style is not mirrored as configured base");
@@ -310,7 +434,7 @@ ok(
);
// Density recipe must land in overlay CSS and have stylesheet consumers.
ok((styleEl as { textContent: string }).textContent.includes("--theme-density-pad") || packSource.includes("--theme-density-pad:6px"), "compact density vars defined in pack builder");
ok(styleText("reasonix-theme-pack-overlay").includes("--theme-density-pad") || packSource.includes("--theme-density-pad:6px"), "compact density vars defined in pack builder");
const compactDraft = draftPackView({
id: "dense",
name: "Dense",
@@ -319,8 +443,8 @@ const compactDraft = draftPackView({
recipes: { density: "compact", corners: "soft" },
});
applyThemePack(compactDraft);
ok((styleEl as { textContent: string }).textContent.includes("--theme-density-pad:6px"), "compact density injected");
ok((styleEl as { textContent: string }).textContent.includes("--theme-row-h:28px"), "compact row height injected");
ok(styleText("reasonix-theme-pack-overlay").includes("--theme-density-pad:6px"), "compact density injected");
ok(styleText("reasonix-theme-pack-overlay").includes("--theme-row-h:28px"), "compact row height injected");
ok(stylesSource.includes("padding: var(--theme-density-pad"), "density pad consumed by cards");
ok(stylesSource.includes("gap: var(--theme-density-gap"), "density gap consumed");
ok(stylesSource.includes("--list-row-height: var(--theme-row-h)"), "density maps to list row height");
@@ -337,6 +461,12 @@ const unguardedTransparencySelectors = transparencySlice
.split("\n")
.filter((line) => line.includes(":root[data-theme-pack]") && !line.includes('[data-theme-has-bg="true"]'));
ok(unguardedTransparencySelectors.length === 0, "token-only packs keep opaque layout surfaces");
ok(
!transparencySlice.includes(".app:not(.app--creation) .code,") &&
!transparencySlice.includes(".app:not(.app--creation) .diff,") &&
transparencySlice.includes(".app:not(.app--creation) .md-code,"),
"pane transparency keeps block code and diff opaque while preserving inline-code styling",
);
ok(stylesSource.includes("var(--theme-pane-card-pct, 88%)"), "home cards consume pane opacity tier");
ok(stylesSource.includes("var(--theme-pane-task-card-pct, 88%)"), "task cards consume pane opacity tier");
ok(stylesSource.includes("var(--tp-pane-card-pct, 88%)"), "preview cards consume the same pane opacity tier");
@@ -423,6 +553,7 @@ ok(experienceSource.includes("ActivateBaseStyle") || experienceSource.includes("
ok(experienceSource.includes("selectedThemeId") || gallerySource.includes("selected"), "selection is frontend state");
ok(gallerySource.includes("loading=\"lazy\"") || gallerySource.includes('loading="lazy"'), "gallery thumbs lazy-load");
ok(gallerySource.includes("ThemePreviewSurface") || gallerySource.includes("theme-preview-surface"), "isolated detail preview");
ok(previewSurfaceSource.includes("theme-preview-surface__code-island"), "theme preview includes real code and diff samples");
ok(gallerySource.includes('themePackKind(pack) === "base"') && gallerySource.includes('variant="thumbnail"'), "base gallery cards render semantic UI thumbnails");
ok(gallerySource.includes('themePackKind(p) === "base"'), "immersive rail renders base-style thumbnails");
for (const style of ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"] as const) {
@@ -546,6 +677,14 @@ ok(stylesSource.includes(".theme-gallery__detail-user-actions") && stylesSource.
ok(stylesSource.includes(".theme-gallery__rail-section-head") && stylesSource.includes(".theme-gallery__rail-section-items"), "immersive rail groups have lightweight headings and item stacks");
ok(stylesSource.includes(".theme-gallery__detail-status"), "active status has dedicated non-button styling");
ok(stylesSource.includes(".theme-editor__setting-hint"), "content-area guidance has dedicated responsive styling");
ok(stylesSource.includes("background: var(--code-bg, var(--bg-soft))"), "code and diff surfaces consume the opaque code background");
ok(
stylesSource.includes("--diff-row-bg: var(--code-add-bg") &&
stylesSource.includes("--inline-diff-row-bg: var(--code-del-bg") &&
stylesSource.includes("background: var(--tp-code-add-bg)") &&
stylesSource.includes("background: var(--tp-code-del-bg)"),
"live and preview diff rows consume the same pre-composited safe backgrounds",
);
ok(localeZh.includes('"settings.themeEditor.safeArea": "界面内容区域"') && localeZh.includes('"settings.themeEditor.safeAreaHint": "选择文字和卡片主要显示的位置;建议避开图片主体。"'), "Chinese content-area copy explains foreground placement");
// Pack overlay stays at :root — Workbench/Creation element-scoped auto-light
@@ -6,7 +6,10 @@ import { fileURLToPath } from "node:url";
import { TEXT_SIZES } from "../lib/textSize";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const styles = [
readFileSync(resolve(testDir, "../styles.css"), "utf8"),
readFileSync(resolve(testDir, "../components/CompactRatioSettings.css"), "utf8"),
].join("\n").replace(/\/\*[\s\S]*?\*\//g, "");
let passed = 0;
let failed = 0;
@@ -120,6 +123,11 @@ eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2",
eq(finalDeclaration(".provider-model-draft__list", "grid-auto-rows"), "min-content", "provider model rows grow with their content");
eq(finalDeclaration(".provider-model-draft__option", "min-height"), undefined, "provider model cards do not force undersized rows");
eq(finalDeclaration(".provider-model-draft__option", "overflow"), "hidden", "provider model cards contain overflowing controls");
eq(finalDeclaration(".compact-ratio-presets", "width"), "100%", "compaction presets use the full settings control width");
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "flex"), "1 1 0", "three compaction presets share the available width equally");
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "flex-direction"), "column", "compaction presets place percentage and strategy on separate lines");
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "min-height"), "44px", "two-line compaction presets keep a stable target height");
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "white-space"), "normal", "compaction labels do not depend on ellipsis for their meaning");
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
@@ -349,5 +357,27 @@ ok(
"narrow workbench layout hides side panels and keeps chat single-column",
);
for (const selector of [
".reasoning__head",
".turn-collapse__reasoning-head",
".process-card__head",
".tool__difflabel",
".msg-memory-citations",
".msg-memory-citations__source",
".msg-memory-citations__note",
".msg-attachment__name",
".msg-attachment__meta",
".msg-pasted-head",
".msg-pasted-expanded",
".msg-edit__input",
".msg-edit__btn",
".msg__send-failed",
":root[data-theme-style] .process-card__kind",
':root[data-theme-style] .msg--assistant > .process-card[data-tone="violet"] .process-card__name',
]) {
const size = finalDeclaration(selector, "font-size");
ok(size !== undefined && !/^[0-9.]+px$/.test(size), `${selector} font size follows the text-size scale`);
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -5,7 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { __emitMockUpdater, type AppBindings } from "../lib/bridge";
import { switchUpdaterChannel, UpdaterProvider, useUpdater } from "../lib/useUpdater";
import { classifyUpdateError, UpdaterProvider, useUpdater } from "../lib/useUpdater";
let passed = 0;
let failed = 0;
@@ -20,25 +20,24 @@ function ok(value: boolean, label: string) {
}
}
function Consumer({ id, checking = false, channel = "" }: { id: string; checking?: boolean; channel?: string }) {
function Consumer({ id, checking = false }: { id: string; checking?: boolean }) {
const updater = useUpdater();
return (
<section>
<output id={`${id}-status`}>{updater.status.kind}</output>
<output id={`${id}-manual`}>
{updater.status.kind === "error" && updater.status.manualHint ? "manual" : ""}
{updater.status.kind === "error" ? updater.status.disposition : ""}
</output>
<output id={`${id}-received`}>
{updater.status.kind === "downloading" ? updater.status.received : ""}
</output>
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check(channel)}>Check</button>}
<button id={`${id}-reset-stable`} type="button" onClick={() => updater.reset("stable")}>Reset stable</button>
<button id={`${id}-reset-preview`} type="button" onClick={() => updater.reset("preview")}>Reset preview</button>
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check()}>Check</button>}
<button id={`${id}-reset`} type="button" onClick={() => updater.reset()}>Reset</button>
{updater.status.kind === "available" && (
<button id={`${id}-download`} type="button" onClick={() => updater.download(updater.status.info)}>Download</button>
<button id={`${id}-apply`} type="button" onClick={() => updater.apply(updater.status.info)}>Apply</button>
)}
{updater.status.kind === "downloaded" && (
<button id={`${id}-install`} type="button" onClick={updater.install}>Install</button>
{updater.status.kind === "error" && updater.status.info && (
<button id={`${id}-retry`} type="button" onClick={() => updater.apply(updater.status.info!)}>Retry</button>
)}
</section>
);
@@ -62,13 +61,17 @@ await act(async () => {
root.render(
<UpdaterProvider>
<Consumer id="banner" checking />
<Consumer id="settings" checking channel="preview" />
<Consumer id="settings" checking />
</UpdaterProvider>,
);
});
ok(document.getElementById("banner-status")?.textContent === "idle", "banner starts idle");
ok(document.getElementById("settings-status")?.textContent === "idle", "settings starts idle");
ok(classifyUpdateError("prepare update: a pending update already exists") === "recovery", "pending update errors require recovery fallback");
ok(classifyUpdateError("prepare update: recover existing handoff backup: operation not permitted") === "recovery", "macOS backup permission errors require recovery fallback");
ok(classifyUpdateError("update: manual update required") === "manual", "manual-only errors prefer the official download");
ok(classifyUpdateError("connection reset by peer") === "retryable", "transient errors remain retryable");
await act(async () => {
(document.getElementById("banner-check-update") as HTMLButtonElement).click();
@@ -92,7 +95,7 @@ const debInfo = {
downloadUrl: "https://example.invalid/download",
assetSize: 42,
};
const installAttempts: Array<{
const applyAttempts: Array<{
requestId: string;
version: string;
channel: string;
@@ -105,20 +108,10 @@ window.go = {
App: {
async CheckUpdate(channel: string) {
checkedChannels.push(channel);
return { ...debInfo, channel: channel === "preview" ? "preview" : "stable" };
return { ...debInfo, channel: "stable" };
},
async DownloadUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
return {
requestId,
version: expectedVersion,
channel: channel === "preview" ? "preview" : "stable",
path: "/tmp/update.deb",
size: 42,
sha256: "abc",
};
},
InstallUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
return new Promise<void>((resolve, reject) => installAttempts.push({
ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
return new Promise<void>((resolve, reject) => applyAttempts.push({
requestId,
version: expectedVersion,
channel,
@@ -134,80 +127,89 @@ await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(checkedChannels[0] === "preview", "settings check forwards the selected Preview channel");
ok(checkedChannels[0] === "stable", "settings check uses the official release channel");
ok(document.getElementById("banner-status")?.textContent === "available", "deb update becomes available");
ok(document.getElementById("settings-status")?.textContent === "available", "deb availability is shared");
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
(document.getElementById("banner-apply") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "downloaded", "deb download completes");
await act(async () => {
(document.getElementById("banner-install") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb install starts authorizing");
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb apply starts authorizing");
ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
phase: "recovering",
requestId: applyAttempts[0].requestId,
version: applyAttempts[0].version,
channel: "stable",
phase: "downloading",
received: 10,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "downloading", "download phase is shared");
ok(document.getElementById("banner-received")?.textContent === "10", "download progress is shared");
await act(async () => {
__emitMockUpdater({
requestId: applyAttempts[0].requestId,
version: applyAttempts[0].version,
channel: "stable",
phase: "verifying",
received: 42,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "recovering", "stale update recovery is shared");
ok(document.getElementById("settings-status")?.textContent === "recovering", "settings observes stale update recovery");
ok(document.getElementById("banner-status")?.textContent === "verifying", "verify phase is shared");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
requestId: applyAttempts[0].requestId,
version: applyAttempts[0].version,
channel: "stable",
phase: "installing",
received: 42,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "installing", "helper phase advances to installing");
ok(document.getElementById("banner-status")?.textContent === "installing", "install phase advances");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
phase: "downloaded",
received: 42,
total: 42,
});
installAttempts[0]?.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "downloaded", "authorization cancellation returns to downloaded");
await act(async () => {
(document.getElementById("banner-install") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters authorizing");
await act(async () => {
installAttempts[1]?.reject(new Error("update: manual update required: system update helper is unavailable"));
applyAttempts[0]?.reject(new Error("update: manual update required: system update helper is unavailable"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "error", "manual reclassification leaves busy state");
ok(document.getElementById("banner-manual")?.textContent === "manual", "manual reclassification offers download fallback");
ok(document.getElementById("settings-status")?.textContent === "error", "manual fallback error is shared");
ok(!!document.getElementById("banner-retry"), "error state exposes retry");
let resolveSavedCheck!: (value: typeof debInfo) => void;
let resolvePreviewCheck!: (value: typeof debInfo) => void;
window.go.main.App.CheckUpdate = (selected: string) =>
// Retry releases the mutex and can start a new apply immediately.
await act(async () => {
(document.getElementById("banner-retry") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters applying");
await act(async () => {
applyAttempts[1]?.resolve();
__emitMockUpdater({
requestId: applyAttempts[1].requestId,
version: applyAttempts[1].version,
channel: "stable",
phase: "relaunching",
received: 0,
total: 0,
});
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "relaunching", "relaunching phase is shared");
let resolveFirstCheck!: (value: typeof debInfo) => void;
let resolveSecondCheck!: (value: typeof debInfo) => void;
let checkCalls = 0;
window.go.main.App.CheckUpdate = () =>
new Promise<typeof debInfo>((resolve) => {
if (selected === "preview") resolvePreviewCheck = resolve;
else resolveSavedCheck = resolve;
checkCalls += 1;
if (checkCalls === 1) resolveFirstCheck = resolve;
else resolveSecondCheck = resolve;
});
await act(async () => {
@@ -215,141 +217,126 @@ await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
});
await act(async () => {
resolvePreviewCheck({ ...debInfo, channel: "preview", latest: "v1.2.0" });
resolveSecondCheck({ ...debInfo, channel: "stable", latest: "v1.2.0" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "available", "newest channel check publishes its result");
ok(document.getElementById("banner-status")?.textContent === "available", "newest official check publishes its result");
await act(async () => {
resolveSavedCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
resolveFirstCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "available", "stale channel check cannot overwrite the newest result");
ok(document.getElementById("banner-status")?.textContent === "available", "stale official check cannot overwrite the newest result");
let oldDownloadRequestId = "";
let oldDownloadVersion = "";
let resolveOldDownload!: (value: { requestId: string; version: string; channel: string; path: string; size: number; sha256: string }) => void;
window.go.main.App.DownloadUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
new Promise((resolve) => {
oldDownloadRequestId = requestId;
oldDownloadVersion = expectedVersion;
resolveOldDownload = resolve;
let oldApplyRequestId = "";
let oldApplyVersion = "";
let resolveOldApply!: () => void;
window.go.main.App.ApplyUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
new Promise<void>((resolve) => {
oldApplyRequestId = requestId;
oldApplyVersion = expectedVersion;
resolveOldApply = resolve;
});
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
(document.getElementById("banner-apply") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "downloading", "Preview download starts");
ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
await act(async () => {
__emitMockUpdater({
requestId: oldDownloadRequestId,
version: `${oldDownloadVersion}-wrong`,
channel: "preview",
phase: "downloaded",
requestId: oldApplyRequestId,
version: `${oldApplyVersion}-wrong`,
channel: "stable",
phase: "installing",
received: 42,
total: 42,
});
});
ok(
document.getElementById("banner-status")?.textContent === "downloading",
"same-request wrong-version progress cannot complete the active Preview download",
document.getElementById("banner-status")?.textContent === "authorizing",
"same-request wrong-version progress cannot advance the active apply",
);
await act(async () => {
__emitMockUpdater({
requestId: oldDownloadRequestId,
version: oldDownloadVersion,
channel: "stable",
requestId: oldApplyRequestId,
version: oldApplyVersion,
channel: "preview",
phase: "downloading",
received: 41,
total: 42,
});
});
ok(document.getElementById("banner-received")?.textContent === "0", "wrong-channel progress cannot update the active Preview download");
ok(document.getElementById("banner-received")?.textContent === "", "retired-channel progress cannot update the active official apply");
await act(async () => {
(document.getElementById("banner-reset-preview") as HTMLButtonElement).click();
(document.getElementById("banner-reset") as HTMLButtonElement).click();
__emitMockUpdater({
requestId: oldDownloadRequestId,
version: oldDownloadVersion,
channel: "preview",
phase: "downloaded",
received: 42,
total: 42,
});
resolveOldDownload({
requestId: oldDownloadRequestId,
version: oldDownloadVersion,
channel: "preview",
path: "/tmp/old.deb",
size: 42,
sha256: "abc",
requestId: oldApplyRequestId,
version: oldApplyVersion,
channel: "stable",
phase: "error",
received: 0,
total: 0,
err: "old apply failed",
});
resolveOldApply();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "idle", "same-channel superseded progress and Promise completion stay ignored");
window.go.main.App.CheckUpdate = async (selected: string) => ({
window.go.main.App.CheckUpdate = async () => ({
...debInfo,
channel: selected === "preview" ? "preview" : "stable",
latest: selected === "preview" ? "v1.2.0" : "v1.1.0",
});
window.go.main.App.DownloadUpdateRequest = async (selected: string, _expectedVersion: string, requestId: string) => ({
requestId,
version: "v1.2.0-preview.99",
channel: selected === "preview" ? "preview" : "stable",
path: "/tmp/new.deb",
size: 42,
sha256: "abc",
channel: "stable",
latest: "v1.2.0",
});
window.go.main.App.ApplyUpdateRequest = async () => {
throw new Error("should not reach when version mismatches from progress only");
};
await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "available", "download result for a different version is rejected");
ok(document.getElementById("banner-status")?.textContent === "available", "official update is available again");
window.go.main.App.DownloadUpdateRequest = async (selected: string, expectedVersion: string, requestId: string) => ({
requestId,
version: expectedVersion,
channel: selected === "preview" ? "preview" : "stable",
path: "/tmp/new.deb",
size: 42,
sha256: "abc",
});
window.go.main.App.ApplyUpdateRequest = async (_channel: string, _expectedVersion: string, requestId: string) => {
// Hang until cancelled by reset so we can prove supersession.
return new Promise<void>((_resolve, reject) => {
applyAttempts.push({
requestId,
version: _expectedVersion,
channel: _channel,
resolve: () => {},
reject,
});
});
};
await act(async () => {
(document.getElementById("banner-download") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
(document.getElementById("banner-apply") as HTMLButtonElement).click();
});
ok(document.getElementById("banner-status")?.textContent === "downloaded", "Preview update is ready for install");
const staleApply = applyAttempts[applyAttempts.length - 1];
ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
await act(async () => {
(document.getElementById("banner-install") as HTMLButtonElement).click();
});
const staleInstall = installAttempts[installAttempts.length - 1];
ok(document.getElementById("banner-status")?.textContent === "authorizing", "Preview install starts");
await act(async () => {
(document.getElementById("banner-reset-stable") as HTMLButtonElement).click();
(document.getElementById("banner-reset") as HTMLButtonElement).click();
__emitMockUpdater({
requestId: staleInstall.requestId,
version: staleInstall.version,
channel: "preview",
requestId: staleApply.requestId,
version: staleApply.version,
channel: "stable",
phase: "error",
received: 0,
total: 0,
err: "old install failed",
});
staleInstall.reject(new Error("old Preview install failed"));
staleApply.reject(new Error("old official apply failed"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded install progress and rejection stay ignored");
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded apply progress and rejection stay ignored");
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable" });
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview" });
await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.getElementById("banner-status")?.textContent === "error", "wrong-channel check response leaves the checking state");
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview", latest: "v1.3.0" });
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable", latest: "v1.3.0" });
await act(async () => {
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -358,43 +345,5 @@ ok(document.getElementById("banner-status")?.textContent === "available", "check
delete window.go;
const channelTransitions: string[] = [];
let releaseSave!: (saved: boolean) => void;
const pendingSwitch = switchUpdaterChannel(
"preview",
(channel) => {
channelTransitions.push(`invalidate:${channel}`);
},
(channel) =>
new Promise<boolean>((resolve) => {
channelTransitions.push(`save:${channel}`);
releaseSave = resolve;
}),
async (channel) => {
channelTransitions.push(`check:${channel}`);
},
);
ok(channelTransitions.join(",") === "invalidate:preview,save:preview", "channel switch invalidates old updater state before persistence resolves");
releaseSave(true);
await pendingSwitch;
ok(channelTransitions.join(",") === "invalidate:preview,save:preview,check:preview", "successful channel persistence checks the target pointer");
await switchUpdaterChannel(
"stable",
(channel) => {
channelTransitions.push(`invalidate:${channel}`);
},
async (channel) => {
channelTransitions.push(`failed:${channel}`);
return false;
},
async (channel) => {
channelTransitions.push(`unexpected:${channel}`);
},
);
ok(!channelTransitions.includes("unexpected:stable"), "failed channel persistence does not check an unselected channel");
await act(async () => root.unmount());
console.log(`\n${passed} passed, ${failed} failed`);
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,28 @@
// Run: tsx src/__tests__/usage-stats-format.test.ts
import { formatUsageTokens } from "../lib/usageStatsFormat";
import { en } from "../locales/en";
import { zhTW } from "../locales/zh-TW";
import { zh } from "../locales/zh";
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const english = formatUsageTokens(10_000, "en");
const chinese = formatUsageTokens(10_000, "zh");
ok(!english.includes("万") && !english.includes("亿"), "English token totals do not use Chinese units");
ok(chinese !== english, "token totals follow the selected desktop locale");
ok(en["settings.modelTab.stats"] === "Usage stats", "English stats tab has a dedicated label");
ok(zh["settings.modelTab.stats"] === "用量统计", "Simplified Chinese stats tab says 用量统计");
ok(zhTW["settings.modelTab.stats"] === "用量統計", "Traditional Chinese stats tab says 用量統計");
if (failed > 0) process.exit(1);
Loaded 100 of 646 files, more files were not shown because too many files have changed in this diff. Show more