Compare commits

...
189 Commits
Author SHA1 Message Date
YHH 6dbabcbf71 Merge pull request #11669 from esengine/docs/release-1.39.7-notes
docs(release): prepare v1.39.7 notes
2026-10-01 22:49:57 -07:00
esengine 8d61174ca0 docs(release): prepare v1.39.7 notes 2026-10-01 22:47:39 -07:00
YHH da0ed97148 Merge pull request #11647 from esengine/fix/v1-legacy-scope
fix(desktop): derive the scope of tab-derived legacy sessions from their project directory
2026-10-01 20:49:29 -07:00
esengine 5421b86ed3 fix(desktop): derive the scope of tab-derived legacy sessions from their project directory
A saved tab pointing into a project's session directory that no registered
scope owned inherited the tab's own scope, so sessions recorded under that
project were judged workspace_conflict against Global. The scope now comes
from the session's recorded root when that root's session directory is the
source directory; any other mismatch still reports a conflict.
2026-10-01 20:17:00 -07:00
YHH 3c8695b470 Merge pull request #11628 from esengine/feat/v1-telemetry-v5
fix(desktop): keep session telemetry across switches for canonical sessions
2026-10-01 12:20:04 -07:00
esengine dd2485645a fix(desktop): persist session telemetry for canonical sessions
Sessions in the v5 store have no transcript path, so the telemetry
sidecar was never written and every session switch reopened with an
empty snapshot. Key the sidecar by session id under the desktop state
home and restore it when a session is opened.
2026-10-01 12:04:23 -07:00
YHH 1eb620fb71 Merge pull request #11625 from esengine/feat/v1-history-head-fold
fix(desktop): fold a turn cut by a page boundary and load the next page at the bottom
2026-10-01 11:58:32 -07:00
esengine 81b255a538 fix(desktop): fold a turn whose user message is on an older page and load the next page at the window bottom
A history page that cuts a turn leaves its tail without a user message, and the fold logic required one, so those tool cards stayed expanded with no fold button. The head group now folds like any settled group, and its manual choice follows the turn once the earlier page loads.

After loading older pages the window drops the newest page and offered only manual buttons to get it back. Reaching the bottom of such a window now requests the next page once, under the same selection and reader-intent guards as the other paging paths.
2026-10-01 11:11:54 -07:00
YHH f8a5df2669 Merge pull request #11597 from esengine/fix/second-fork-source-receipt
fix(desktop): a second fork of one source no longer fails the workspace load
2026-10-01 05:59:02 -07:00
esengine b777bde623 fix(desktop): a second fork of one source no longer fails the workspace load
attachDesktopSession registered the manifest source for every session whose
own receipt was missing, but RecordSource owns a source key globally, so the
second fork naming the same parent source hit ErrMutationConflict and made the
whole workspace history unloadable. Skip registration when the source key is
already mapped.

Refs #11587
2026-10-01 05:42:56 -07:00
YHH a879773823 Merge pull request #11576 from esengine/fix/goal-edit-no-prompt
fix(desktop): edit Goal in an in-app form instead of window.prompt
2026-10-01 05:02:21 -07:00
esengine fccddf170d fix(desktop): polish the inline Goal edit form
Compact layout, side-by-side Save/Cancel, error colour, Esc closes only the form and returns focus. Refs #11573
2026-10-01 04:38:56 -07:00
esengine cbba10ae8c fix(desktop): edit Goal in an in-app form instead of window.prompt
Electron renderers throw on window.prompt(), so Edit goal did nothing.
Refs #11573
2026-10-01 03:19:16 -07:00
YHH a649c72188 Merge pull request #11512 from XTLine/fix/remote-switch-history-stall
fix(desktop): preserve remote submissions and history across busy session switches / 修复远端忙碌会话切换时的消息提交与历史加载
2026-10-01 02:17:00 -07:00
XTLine eaafef675b fix(desktop): refresh generated metadata after module extraction 2026-10-01 16:04:32 +08:00
XTLine 3db246f5fc refactor(remote): extract session submission and retry modules 2026-10-01 15:08:50 +08:00
XTLine bbd4e65c69 test(desktop): wait for remote controller teardown 2026-10-01 15:05:53 +08:00
YHH 164cfef2dd Merge pull request #11491 from esengine/docs/release-1.39.6-notes
docs(release): prepare v1.39.6 notes
2026-09-30 18:11:43 -07:00
esengine 2ce60e12f0 docs(release): prepare v1.39.6 notes 2026-09-30 18:07:31 -07:00
YHH 136bd5fcd1 Merge pull request #11487 from esengine/ci/fix-release-control-timeout-assert
test(ci): accept the 25m desktop Go timeout the Windows groups now pass
2026-09-30 18:03:27 -07:00
esengine 78395619f1 test(ci): accept the 25m desktop Go timeout the Windows groups now pass 2026-09-30 17:51:18 -07:00
XTLine 2c04fece87 fix(desktop): paint resident remote sessions on switch 2026-10-01 00:48:13 +08:00
XTLine 38028e0f86 test(desktop): cover resident remote session paint 2026-10-01 00:48:13 +08:00
XTLine aaaf521163 fix(desktop): show busy submits in the queue 2026-10-01 00:48:13 +08:00
XTLine 02c1732e68 test(desktop): cover busy submit queue receipts 2026-10-01 00:48:13 +08:00
XTLine 219aadd409 fix(desktop): retry transient submit windows 2026-10-01 00:48:13 +08:00
XTLine 9fb4dd8fb0 test(desktop): cover transient submit windows 2026-10-01 00:48:13 +08:00
XTLine 0004d81e52 fix(serve): preserve busy identity sessions on switch 2026-10-01 00:24:44 +08:00
XTLine 8b6b63c92c test(serve): cover busy identity session switches 2026-10-01 00:23:44 +08:00
YHH 4115cc702e Merge pull request #11431 from SivanCola/codex/config-upgrade-compat-fix
Fix legacy project configuration warnings in 1.x / 修复 1.x 旧项目配置误告警
2026-09-30 08:54:05 -07:00
SivanColaandxiaoyuangede 28b3300cc5 fix(config): stop warning on compatible legacy project declarations
Problem: The 1.38.3 to 1.39.5 upgrade produces persistent configuration
banners for equivalent paths and historical project permission declarations.

Root cause: Declaration filtering runs before saved project grants are merged,
and exact string comparisons miss permission coverage and directory identity.
Unapproved permission declarations are also treated as configuration failures.

Fix: Fold existing workspace grants into the held authority before narrowing,
reuse permission coverage and filesystem identity checks, and retain unapproved
declarations in the existing refusal view without banner warnings. Keep grants
workspace-scoped and preserve all approval, ask and deny constraints.

Keep doctor's sandbox layering hint with accurate narrowing wording and retain
key/value warnings. The existing CLI config/language loading and plain warning
origin are preserved; no new diagnostic attribution or wire contract is added.

Verification: Permission/path/grant revocation regressions, read-only file
preservation, doctor detail/source and CLI language tests passed. Focused race
checks and repository structural lint passed; full root and Desktop caller
verification are recorded in the PR.

Refs #11430

Co-authored-by: xiaoyuangede <[email protected]>
2026-09-30 23:01:02 +08:00
YHH 369f55cbf5 Merge pull request #11415 from jf200s/pr/main-v2-bugfixes
fix: stop goroutine leaks and repair Windows-hostile tests / 修复 goroutine 泄漏与 Windows 环境测试失败
2026-09-30 07:10:34 -07:00
F b0b7cfa34c test(desktop): use an integer range in the emit regression loop / 修复 golangci-lint intrange 2026-09-30 21:20:46 +08:00
YHH 77eab9e8e2 Merge pull request #11414 from esengine/fix/11412-loadfull-streaming
fix(desktop): keep 'load full' on a streaming reasoning block
2026-09-30 05:38:06 -07:00
esengine f3452e3a82 fix(desktop): keep chat.loadFull on a streaming reasoning block
The loaded full text was only honoured while the item was the same object or
the text was unchanged. A streaming block replaces its item on every chunk, so
the view snapped back to the 8000-char preview and the button reappeared.
While streaming, loading now follows the live reasoning; on settlement the
canonical full text is loaded once.
2026-09-30 05:23:27 -07:00
F a311dd464e chore: pin desktop suite timeout to 25m / 为桌面套件固定 25 分钟超时
Makefile desktop-test/-short/-times and ci.yml (via scripts/desktop-windows-go-tests.mjs testArgs) pass -timeout=25m: the full suite needs ~25 min locally, past go's 10-minute default panic. The -short target alone was measured at 933s.
2026-09-30 20:05:22 +08:00
F 9e32035f32 test(desktop): skip packager manifest test when bash cannot run / PATH 中的 bash 无法执行时跳过打包清单测试
Same root cause as the e2ebench fix: the System32 WSL relay stub satisfies LookPath("bash") but cannot run scripts, so the packager dies with a WSL error instead of its own validation output. Pre-existing on main-v2; deterministic failure under the native Windows registry PATH (3/3 subtests), green under a real bash. The probe requires bash -c true to succeed within 3s and skips otherwise.
2026-09-30 20:05:11 +08:00
F b124e2a5fa test: repair Windows-hostile tests and the HTTP/2 mock flake / 修复 Windows 环境测试失败与 HTTP/2 mock 偶发
All pre-existing on main-v2; upstream CI (Linux) never satisfies the exposure conditions (native Windows PATH, 8.3 short TEMP, load windows).

- authentication_app_test: emit callback sends non-blocking instead of wedging the shared asyncRuntimeEmitter on a second send
- tabs_topic_test: waitForTabReady deadline 5s -> 15s under load
- shellrun / clientio / project_scope_effect: PowerShell equivalents for POSIX-only commands
- e2ebench: requireRealBash probe; LookPath matches the System32 WSL stub
- statusline_test: cat -> more on Windows, mirroring TestRunStatuslineCmd
- persistentshell: expand 8.3 short TEMP paths via GetLongPathName
- provider HTTP/2 mock: drain inbound frames before close; a Windows close with unread data sends RST and clobbers the GOAWAY/RST_STREAM just written (upstream #11152; -count=200 green vs ~5/100 before)
- resume_test: restore desktop-projects.json so -count>=2 stays clean
2026-09-30 20:05:11 +08:00
F 7dcfb7fa46 fix: stop botBridgeHub and recordDispatcher goroutine leaks / 修复 botBridgeHub 与 recordDispatcher goroutine 泄漏
Both leaks exist verbatim on main-v2; a 10-minute goroutine dump showed 1279 botBridgeHub and 112 recordDispatcher goroutines stranded.

botBridgeHub: idempotent Close (closed chan + sync.Once) wired into App shutdownBody via stopBotBridge; the queue channel is never closed because enqueues race the close. recordDispatcher: per-directory shutdown via CloseRecordDispatchers in closeCLIUsageCatalogs; late enqueues keep the existing best-effort drop. Regression: TestBridgeCloseStopsRunGoroutine.
2026-09-30 20:05:11 +08:00
YHH 9a165ea6dc Merge pull request #11405 from esengine/fix/turn-fold-manual-11338
fix(desktop): let every ended turn be folded by hand
2026-09-30 02:32:17 -07:00
esengine 5a7fd42d6a fix(desktop): let every ended turn be folded by hand
The process fold control was only projected for a turn that ended cleanly
with a final answer. A stopped turn, an errored turn, or one holding an
unfinished tool or a leftover streaming item stayed expanded and showed no
control at all.

Every ended turn now gets the control. Only a settled turn still starts
folded; the others keep starting open, and a manual choice is stored
explicitly per turn so it survives republication and a session revisit
even when the turn's settled state changes afterwards.
2026-09-30 02:12:54 -07:00
YHH 7da1197efc Merge pull request #11401 from esengine/docs/readme-certum-signing
docs: say Windows installers are signed with Certum, not SignPath
2026-09-30 02:07:57 -07:00
esengine 9aad6d8b4c docs: say Windows installers are signed with Certum, not SignPath 2026-09-30 01:50:29 -07:00
YHH 3da4a00263 Merge pull request #11396 from esengine/ci/cli-publish-frozen-switch
ci(release): add a CLI_PUBLISH_FROZEN switch so 1.x can stop writing the CLI channels
2026-09-30 01:09:13 -07:00
esengine a9cd939c1f ci(release): add a CLI_PUBLISH_FROZEN switch that stops 1.x from writing the CLI channels
With the repository variable CLI_PUBLISH_FROZEN set to 'true', a 1.x release no
longer writes the Homebrew cask, the R2 cli/{stable,preview}/latest.json
pointers, or npm latest/canary/next. npm packages are still published, under
the legacy-v1 dist-tag (npm rejects v1: it parses as a semver range). The
GitHub release, its immutable R2 record, and Desktop are unchanged. Unset, the
workflows behave exactly as before.
2026-09-30 00:50:06 -07:00
YHH ddf6ca6820 Merge pull request #11386 from esengine/fix/interrupted-recovery-precedence
fix(agent): new user message outranks the interrupted-turn recovery block
2026-09-29 23:34:19 -07:00
esengine c2d5560aac fix(agent): state that the new user message outranks the interrupted-turn recovery block
The recovery block said the interrupted turn was not a new task but never
said which instruction wins, so a fresh, unrelated user message could be
read as a cue to resume the old task. The block now says the user's new
message takes precedence.
2026-09-29 23:06:15 -07:00
YHH 51a3ed710c Merge pull request #11339 from esengine/revert/11257-http1-compat
Revert "fix(provider): add config-only HTTP/1.1 compatibility" (#11257)
2026-09-29 08:45:01 -07:00
esengine dd7625ca58 Revert "fix(provider): add config-only HTTP/1.1 compatibility" (#11257)
main-v2 (1.x) takes bug fixes only. #11257 added a new configuration
setting (http1_only) and, as a side effect, changed the default proxy
route used by the desktop credential proxy for SSH remote workspaces:
those routes now go through netclient.NewTransport(cfg.NetworkProxySpec())
even when http1_only is off, which is a default-behaviour change, not a
fix. Any default-behaviour change on the fixes-only 1.x line is reverted
per project policy.

The feature continues on studio in #11293.

This reverts commit 20134210be.
2026-09-29 08:20:58 -07:00
SivanCola 20134210be fix(provider): add config-only HTTP/1.1 compatibility / 连接级 HTTP/1.1 兼容修复 (#11257)
* Add per-connection HTTP/1.1 compatibility for model providers

Problem: Some DeepSeek and compatible endpoints negotiate HTTP/2, accept a
written request, then fail with PROTOCOL_ERROR before returning headers.

Root cause: Provider transports always attempted HTTP/2 and offered no
connection-scoped fallback. Responses also silently ignored invalid proxy
settings, and credential-proxy routes used inconsistent network policy.

Fix: Add an explicit HTTP/1.1 option for each provider connection, expose it
in Desktop settings and structured protocol errors, and apply it to provider
requests, probes, discovery, and credential-proxy upstreams. Preserve proxy
and TLS validation, never replay a failed request automatically, and fail
closed on invalid Responses proxy settings. Record the selected HTTP mode.

Verification: Root Go suite, Desktop model-settings/proxy tests, focused race
tests, frontend typecheck/build/tests, browser interaction, repolint, and a
v1.39.5 previous-reader configuration round trip passed before publication.

* test(desktop): scope provider address assertions to connection fields

Problem: desktop-frontend fails two provider editor assertions, which also
fails the lint aggregate on the HTTP/1.1 compatibility PR.

Root cause: address migration tests prohibit radio buttons across the entire
editor, incorrectly rejecting the independent HTTP transport controls.

Fix: share a scoped assertion for new and existing connections that requires
exactly one request-address input and no address-mode radio buttons within
the connection fields. Keep transport controls and address migration checks.

Verification: reproduced both original failures; all 57 provider editor
assertions now pass. Production and test TypeScript checks, targeted ESLint,
repository lint and git diff checks pass. The full CI frontend test plan is
running separately; cross-platform CI will validate this commit.

* fix(provider): restrict compatibility advice to protocol errors

Problem: HTTP/2 overload errors could suggest a protocol change, and the TLS regression matched error text.

Root cause: the UI gated only on the transport category; the fixture bypassed the existing typed error classifier.

Fix: require PROTOCOL_ERROR and classify the real transport error structurally. Cover overload, refusal, missing, and non-protocol codes.

Verification: focused HTTP/1.1 TLS tests and compatibility action tests pass.

* fix(provider): narrow HTTP compatibility to persisted connection policy

Problem: the maintenance PR bundled a transport workaround with new desktop
settings, RPC fields and a one-click workflow, before user-network validation.

Root cause: the user-facing feature and the minimum connection policy were
delivered as one change despite different release and validation boundaries.

Fix: retain only opt-in config transport behavior and preserve the full UI on
a separate branch. Existing desktop edits retain the config-only field; probes
and model discovery read it from the saved connection. Remove the new desktop
contract and diagnostic fields. Document manual restart and same-network A/B
validation without replaying written requests or claiming an EOF repair.

Verification: provider/netclient suites, config/boot protocol tests, desktop
save/probe/discovery/credential-proxy regressions, root and desktop lint,
generated-contract freshness and diff checks pass. User-network validation
remains outstanding.
2026-09-29 22:35:16 +08:00
YHH f41512ed00 Merge pull request #11319 from KHG420/codex/1x-active-legacy-version-11312
fix(desktop): keep mapped active legacy versions when archiving siblings
2026-09-29 07:29:25 -07:00
xiexian df6b737fba fix(desktop): preserve mapped active legacy versions when archiving siblings 2026-09-29 21:04:36 +08:00
YHH d0572a4f5a Merge pull request #11303 from KHG420/codex/1x-manual-sort-reset-11296-clean
fix(desktop): allow resetting manual session order
2026-09-29 05:47:32 -07:00
YHH ee72c66af6 Merge pull request #11309 from esengine/fix/v1-11301-legacy-trash-rematerialize
fix(desktop): archive unnamed-fork and concurrent versions of a legacy row together
2026-09-29 05:43:10 -07:00
esengine a0df39a7a1 fix(desktop): archive unnamed-fork and concurrent versions of a legacy row together
#11187 grouped the versions of a legacy transcript by walking parent links
only through rewind heads. Transcripts written by 1.38.x also carry versions
as unnamed fork heads ("fork from this turn", /branch <turn>) and as rewinds
made on a second writer's concurrent head; each of those stayed its own
group, so every "Move to trash" still archived one version and brought the
next same-title row up.

A head now starts a conversation of its own only when it is a fork with a
name. Siblings already archived or removed are skipped: re-archiving them
compares the whole transcript's fingerprint, which any other head's write
moves, and materialized another copy of an unchanged version.
2026-09-29 05:07:19 -07:00
YHH 43dc1778d2 Merge pull request #11273 from KHG420/codex/1x-bot-session-readonly-7197
fix(desktop): open embedded bot sessions through shared runtime
2026-09-29 04:10:37 -07:00
xiexian 923370645c fix(desktop): allow resetting manual session order 2026-09-29 18:37:58 +08:00
YHH aad51d1970 Merge pull request #11294 from esengine/fix/v1-11274-import-failure-cause
fix(desktop): show why a historical import failed and where to review it
2026-09-29 03:33:44 -07:00
esengine 3575809b3e fix(desktop): show why a historical import failed and where to review it
A historical preparation that failed reached the user as one sentence for
every cause: runHistoricalPreparation mapped each error that was not a busy
source to import_failed and kept the cause only in service.log, and the
banner discarded thrown errors and treated an open failure after a
successful import as a failed import. The sentence promised recovery
details that nothing on the banner led to.

The preparation now projects the identity the producer attached (workspace
conflict, replay limit, damaged history, unsupported format, state
conflict, changed source) and carries a bounded detail. The banner and the
historical list render the class with the detail behind the details
toggle, an open failure is reported as such, and a recovery-details action
opens Settings > Storage, where the historical list and recovery review
live.

Refs #11274
2026-09-29 03:14:14 -07:00
YHH b63d4151a2 Merge pull request #11282 from esengine/fix/v1-offline-session-load
fix(desktop): show why a session's history failed to load
2026-09-29 02:39:39 -07:00
YHH 9b6437fe06 Merge pull request #11271 from esengine/fix/v1-11254-stale-conn-retry
fix(provider): resend once when a pooled idle connection was already dead
2026-09-29 02:38:53 -07:00
YHH 9eab98f5e7 Merge pull request #11270 from esengine/fix/v1-11241-new-session-list
fix(desktop): keep new sessions reachable in a manually ordered sidebar
2026-09-29 02:38:08 -07:00
YHH 425c59b2c5 Merge pull request #11086 from KHG420/codex/1x-remote-session-presets
fix(serve): persist permission presets per remote session
2026-09-29 02:24:44 -07:00
YHH 00b14555bd Merge pull request #11253 from esengine/fix/v1-powershell-prelude
fix(shell): defensive PowerShell prologue, UTF-8 redirects on 5.1, Store pwsh detection
2026-09-29 01:50:49 -07:00
esengine c92312d04d fix(desktop): show why a session's history failed to load
Both local history-read failure paths replaced the reader's error with
the generic "failed to load history" sentence, so the recovery banner's
Details repeated the summary and the composer stayed on "Send after
recovery" with no visible cause. Keep the summary and append the
follow client's stage.reason identity and the host message.
2026-09-29 01:44:41 -07:00
esengine c9e9da581c fix(provider): resend once when a pooled idle connection was already dead
After an idle pause, the next turn could fail with `unexpected EOF` (HTTP/2)
or `EOF` (HTTP/1.1) although the request never reached the provider: the
transport reused a pooled connection that a local proxy or the peer had
dropped while idle, and it closed on the first write. The standard library
does not retry a POST in this state, and since #10730 SendWithRetry sends
exactly once, so the failure reached the user instead of being absorbed by
the old backoff loop. Regression since 1.39.0.

SendWithRetry now resends once on a fresh connection when, and only when,
all of these hold, read from httptrace and error identity rather than text:
the attempt went out on a reused idle connection, no response byte arrived,
the error is io.EOF / io.ErrUnexpectedEOF / a connection reset or abort
(Winsock codes on Windows), and the context is not done. Idle connections
are closed before the resend. Both attempts are counted, so usage reports
the real request count. Fresh-connection failures, TLS handshake timeouts
and every non-OK status still return to the caller unchanged.

Closes #11254
2026-09-29 01:39:46 -07:00
esengine 2b6128ff27 fix(shell): make the PowerShell prologue defensive and fix 5.1 redirect encoding
The prologue was one chained assignment,
`$OutputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8`.
When the Console assignment throws (no console attached, or
ConstrainedLanguage refusing .NET property writes) neither encoding was
set and every command printed a 7-8 line error first. Each assignment is
now its own try/catch and .NET properties are only touched in
FullLanguage. Both encodings are BOM-less UTF8Encoding instances, so text
piped to native programs no longer starts with EF BB BF.

On Windows PowerShell 5.1 only, the shell tool also sets
$PSDefaultParameterValues['Out-File:Encoding']='utf8', so '>' and Out-File
write UTF-8 instead of UTF-16LE. '*:Encoding' is deliberately not set: it
would make Get-Content misread ANSI (GBK) files. Hooks keep only the output
part of the prologue, since they run the user's own scripts.

PowerShell discovery adds the Store install's execution alias
(%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe). An alias is a zero-byte
reparse point that stats fine but may fail to start the packaged app, so an
alias candidate (from the list or from PATH) must launch once before it is
selected; regular executables are taken as found.

The Windows PowerShell tool description now points file reads and writes at
read_file/edit_file/write_file instead of Get-Content/Set-Content/Out-File/'>',
which changes the Windows tool-schema golden (one-time prefix-cache miss).
2026-09-29 01:19:18 -07:00
esengine 2370aa5aad fix(desktop): keep new sessions reachable in a manually ordered sidebar
Dragging one conversation turns on manual ordering for its project. The
manual order was seeded from the registry's attach order, which is oldest
first, so the first drag flipped the whole list; and every new session was
then appended to the tail. The sidebar pages the list from the top, so with
hundreds of conversations a new one was visible only while active and
vanished from the list as soon as the user switched away (#11241).

A new unparented session now goes to the head of a manual order, and the
first move seeds the manual order from the listing the sidebar was showing,
in the sort mode the sidebar sends with the move.
2026-09-29 01:19:05 -07:00
YHH 1a1ccbef33 Merge pull request #11238 from SivanCola/codex/fix-goal-lifecycle-arguments
fix(goal): tolerate and disclose unused lifecycle fields / 兼容并说明未生效的目标操作参数
2026-09-29 01:00:28 -07:00
xiexian 7cefa9c194 Merge remote-tracking branch 'origin/main-v2' into codex/1x-bot-session-readonly-7197 2026-09-29 15:51:22 +08:00
xiexian 522001b7be fix(desktop): open embedded bot sessions through shared runtime 2026-09-29 15:46:34 +08:00
SivanCola 213b8fa2db fix(goal): disclose fields ignored by lifecycle actions
Problem: lifecycle calls accept inactive fields, but a successful result did
not explicitly tell the model that conflicting supplied values were unused.

Root cause: action-specific mutation filtering had no corresponding result
feedback, leaving callers to infer it from the returned Goal snapshot.

Fix: identify unused non-null text fields and supplied unused round limits
in the result instruction after a successful owner update. Preserve terminal
instructions and authoritative state without another read or new schema.
Remove upgrade-history notes from contract docs and the redundant comment.

Verification: ignored-field regression fails against the preceding code;
update-goal and cold-restored continuation tests pass, including race checks.
Builtin/boot suites, both shell golden guards, go vet, and make lint pass.
2026-09-29 15:38:34 +08:00
SivanCola 8580ea084b fix(goal): consume update arguments by lifecycle action
Problem: resume calls fail when models echo the objective and populate unused
optional fields after get_goal.

Root cause: update_goal rejects fields for other actions, including empty
blockers and null round limits, before reaching the lifecycle owner.

Fix: consume only the selected action's mutation fields and allow nullable
text placeholders. Preserve exact revisions, authority checks, edit semantics,
and propagated lifecycle failures. Document the contract in both languages.

Verification: goal argument and cold-recovery regressions, Bash/PowerShell
golden guards, go vet ./..., and make lint pass. Static tool schema changes
cause a one-time cache-prefix change on upgrade; system prompts are unchanged.
2026-09-29 14:28:05 +08:00
YHH 14ed15ba5e Merge pull request #11232 from esengine/fix/v1-11229-windows-command-line-limit
fix(bash): name the Windows command-line limit instead of "filename too long"
2026-09-28 23:08:04 -07:00
xiexian a42067d245 test(serve): authenticate remote preset route fixture 2026-09-29 13:44:49 +08:00
xiexian de2c07c46f Merge remote-tracking branch 'origin/main-v2' into codex/1x-remote-session-presets 2026-09-29 13:43:05 +08:00
esengine 1ad0716a37 fix(bash): name the Windows command-line limit instead of "filename too long"
A shell command whose command line exceeds CreateProcessW's 32,767 UTF-16
unit ceiling never starts; the model was told "fork/exec powershell.exe:
The filename or extension is too long." and read that as a file-size
problem. On Windows the PowerShell tool passes the whole script as one
-Command argument, so writing a large block of text (e.g. a Chinese
document in a here-string) hits this every time.

The host now counts the command line exactly as os/exec builds it and,
when it will not fit, refuses the call before launch with a typed error
(errCommandLineTooLong) that states the size, the limit, and that nothing
ran, and points at write_file/edit_file. The execution is recorded as
not run / not started.

Refs #11229
2026-09-28 22:09:56 -07:00
YHH e6b520c39b fix(config): workspace configuration only narrows the user's settings (#11209)
A workspace's reasonix.toml and .reasonix/settings.json are read as
untrusted input.

- [sandbox]: bash may only move to enforce and network only to false;
  forbid_read adds to the user's; allow_write entries and workspace_root
  apply only when they resolve inside the workspace. ${VAR} in sandbox
  and proxy paths expands from the process environment only.
- [permissions] mode, allow and allow_dynamic_bash, [network] proxy
  settings, [bot], and [desktop] default_tool_approval_mode, language
  and currency are read from the user config only; deny and ask add to
  the user's. An "always allow" or write-access grant given in a
  workspace is stored under the Reasonix home (project-grants.json)
  rather than in the checkout. The desktop no longer adopts a workspace
  reasonix.toml into the user config.
- Programs a workspace declares (hooks, language servers, providers,
  tools.search.rg_path, tools.shell.path and [browser] launch settings)
  run only once approved for that folder. An approval records the
  declaration and a digest of the files it names, is kept under the
  Reasonix home, and is checked again before each start; a change
  refuses the start with ErrProjectProgramChanged. `reasonix trust`
  lists and approves them, and the desktop hooks settings show waiting
  project hooks with an Approve action. Model settings that named only a
  held-back provider fall back to the user's choice.
- Single program paths may not point into the workspace, a temporary
  directory, an allow_write root or a toolchain cache.
- Sandbox write directories are resolved without following links that
  sit inside a writable directory, and host write directories keep the
  identity first seen. On Linux the toolchain caches are listed whether
  or not they exist yet.
- Every ignored value is reported as a load warning with a typed reason
  (Config.IgnoredProjectSettings); held-back hooks raise a notice.
2026-09-28 18:24:30 -07:00
YHH f733345587 Merge pull request #11210 from esengine/docs/v1.39.5-notes
docs(release): prepare v1.39.5 notes
2026-09-28 18:11:41 -07:00
esengine 4f1637e05f docs(release): prepare v1.39.5 notes 2026-09-28 18:09:38 -07:00
YHH 8f7e4a0748 Merge pull request #11190 from KHG420/codex/1x-project-state-collision-10682
fix(desktop): isolate newly added project slug collisions
2026-09-28 11:41:32 -07:00
xiexian 3770cb3856 fix(desktop): satisfy project update lint checks 2026-09-29 01:30:41 +08:00
xiexian 2835c52d2b fix(desktop): preserve legacy project state on re-add and import 2026-09-29 01:19:38 +08:00
YHH 64638bccd1 Merge pull request #11185 from KHG420/codex/1x-validate-permission-rules-6692
fix(desktop): reject permission rules for unregistered tools
2026-09-28 08:52:33 -07:00
YHH ae31f7388e Merge pull request #11188 from esengine/fix/desktop-11168-logo-theme
fix(desktop): tint the title-bar logo with the theme accent
2026-09-28 08:42:49 -07:00
xiexian ade8df6c1f Merge main-v2 into project collision fix 2026-09-28 23:35:09 +08:00
xiexian 420718b717 fix(desktop): explain MCP permission rules require exact tools 2026-09-28 23:21:43 +08:00
esengine 4e8dc79184 fix(desktop): tint the title-bar logo with the theme accent
The title-bar mark was an <img> of logo-symbol.svg, whose fill is fixed
blue, so it ignored the theme and was hard to see on dark themes (about
2.8:1 against the bar). It is now the same SVG used as a mask over
var(--accent), so it follows each theme's accent (4.6-9.7:1 on dark).

The startup splash and the static boot shell keep the brand blue: they
show before or while the theme loads.
2026-09-28 08:21:16 -07:00
xiexian 97f5a265de fix(desktop): accept configured disconnected MCP permission tools 2026-09-28 23:18:08 +08:00
YHH b76822bc19 Merge pull request #11187 from esengine/fix/desktop-legacy-archive-rematerialize
fix(desktop): archiving a legacy version row archives every version of it
2026-09-28 08:17:15 -07:00
xiexian c2974487dd fix(desktop): isolate newly registered project slug collisions 2026-09-28 23:12:33 +08:00
esengine 1d75becebc fix(desktop): archiving a legacy version row archives every version of it
Every rewind keeps the head it left as a live version, and the sidebar
expands each live head of a legacy transcript into its own row carrying
the conversation's title. "Move to trash" archived only the clicked head:
it materialized that version into desktop-sessions-v5 (one mapping, one
session state, one presentation entry) and the next version, same title,
moved up into its place. Repeated clicks walked the versions one by one,
so the list never visibly shrank and each click added a ~2 MB record
pointing at the same legacy transcript.

Re-archiving the same head was already idempotent; the records are
distinct versions, not copies of one.

Archiving a legacy head now also archives the other live heads that are
versions of the same conversation: heads linked to it through rewind
parents, read from the transcript's own head list. Named forks start a
conversation of their own and are left alone, as are retired and
concurrent heads. Each version goes through the existing source archive
path, so it is materialized at most once and a repeat is a no-op.
2026-09-28 07:44:23 -07:00
YHHandesengine d39c0ff888 fix(migrator): refuse a flat unit without the Electron shell and restore the previous release (#11182)
The 1.18.0-1.19.2 file updaters accept the current electron-v1 manifest and
publish only reasonix-desktop, reasonix and reasonix-guard out of the Linux
tarball; app/ never reaches the install. The migrator shipped as
reasonix-guard then activated versions/<v> without a shell, wrote
current.json, committed the legacy update transaction as healthy and removed
the flat files. The new desktop exits with "no Electron desktop shell", and
nothing is left to roll back to.

An absent or incomplete app/ tree now stops the flat migration before any
commit: no version directory, no current.json, no health mark, no cleanup.
When the old updater's transaction is still pending for this install and
version, the migrator rolls it back through the repair transaction (Unix
only) and relaunches the restored release through its Guard, the way the
legacy updater itself relaunches. The refusal carries errShellMissing and is
appended to <home>/desktop-shell/logs/legacy-migrate.log, since a migrator
started by an old updater has no terminal.

Tests reproduce the legacy updater's exact transaction steps from inside the
install (subprocess copies of the test binary, as the repair transaction
binds the running executable to the install directory), then assert the
rollback, and that a unit carrying app/ still commits the transaction.

Co-authored-by: esengine <[email protected]>
2026-09-28 07:29:54 -07:00
xiexian 24dc82f313 fix(desktop): keep permission command contract owner stable 2026-09-28 22:10:42 +08:00
xiexian b73f1174b2 test(desktop): preserve existing rules on rejected save 2026-09-28 21:59:06 +08:00
xiexian d62e151fcb fix(desktop): reject permission rules for unregistered tools 2026-09-28 21:53:03 +08:00
YHH d5c4e196a6 Merge pull request #11171 from SivanCola/codex/context-compaction-recovery
Bound compaction waits and preserve context on failure / 限制整理等待并保留失败上下文
2026-09-28 04:33:53 -07:00
SivanCola e8e7af5996 Document intentional exact error identity checks
Problem: Errorlint rejects direct comparisons in the error-ownership regression
and in the bare-deadline normalization branch.

Root cause: These checks intentionally distinguish an unchanged error from an
additional wrapper; errors.Is alone cannot prove that stronger invariant.

Fix: Document narrowly scoped errorlint exceptions for exact identity checks.
The bare sentinel may be replaced, while enclosing errors retain their wrappers.
No execution behavior changes.

Verification: Repository-pinned golangci-lint reports zero issues, repolint is
clean, and the focused error-boundary regressions pass with race detection.
2026-09-28 19:02:25 +08:00
SivanCola 7e45e66110 Preserve provider failure diagnostics across cleanup and restart / 保留收尾与重启后的模型请求失败诊断 (#11152)
* fix: preserve provider failure diagnostics across cleanup and restart

Problem: Failed requests could reopen without their failure identity or the
transport evidence needed to investigate them.

Root cause: Cleanup replaced terminal recovery metadata, HTTP/2 protocol
errors fell through classification, and request observations were process-local.

Fix: Preserve failure metadata at the shared cleanup boundary, recognize
HTTP/2 transport codes without enabling retries, and persist bounded redacted
request evidence atomically with turn completion. Localize failure summaries
and document additive persistence compatibility.

Verification: Full root Go suite, owning-package and focused race tests,
desktop history/export regressions, frontend tests and typecheck, Chromium
interaction checks, Windows provider cross-compilation, and repolint passed.

* fix: align diagnostic contracts and isolate lifecycle test fixtures

Problem: CI rejected stale desktop contracts and an error type assertion;
Windows lifecycle tests also timed out around filesystem finalization.

Root cause: The additive diagnostic field was not regenerated into the host
contract. Transport classification used a direct error assertion. Functional
checkpoint and cancellation tests unnecessarily coupled live state assertions
to multiple durable writes under short completion deadlines.

Fix: Regenerate the contract, reject wrapped API errors using errors.As, and
use existing in-memory checkpoint/controller fixtures for live semantics.
Retain all assertions and dedicated durable maintenance, checkpoint reload,
and cold-export coverage without changing production timeouts or CI gates.

Verification: Pinned golangci-lint 2.12.2, repolint, focused provider/control
tests, three race-test repetitions, checkpoint persistence reload, frontend
typecheck, and diff checks passed locally.

* fix: structure transport diagnostics and bound failed-turn evidence

Problem: Transport classification depended on error wording, UI projections
dropped structured diagnostics, successful requests persisted peer addresses,
and bounded request history could silently appear complete after eviction.

Root cause: Diagnostic identity was converted back to prose across transport
and presentation boundaries, while lifetime-wide request eviction lacked
bounded per-turn accounting and unsuccessful-turn persistence gating.

Fix: Classify exported HTTP/2 types and exact stdlib type/code identities;
carry diagnostic fields through all notice/history paths; persist only failed,
interrupted, or recovery-required turn evidence; and record per-turn dropped
counts and truncation, marking unavailable accounting incomplete.

Verification: Typed and real TLS connection/stream/GOAWAY tests, provider/control
race checks, cold-export and success-persistence regressions, per-turn loss
tests, frontend projection tests, Chromium three-locale interactions, production
frontend build, and bundle budgets passed. Preserve model bytes and retry policy.

* fix: keep optional diagnostics from blocking terminal commits

Problem: Encoding optional provider evidence could prevent required turn-end
events from committing. Persisted free-text transport errors duplicated the
structured evidence, and generated detail text duplicated HTTP/2 codes.

Root cause: Optional diagnostic errors escaped the shared terminal-event
builder, while transport prose was retained at multiple presentation boundaries.

Fix: Warn and omit failed diagnostic records while preserving required terminal
events. Remove transportError and its byte truncation. Leave transport-code
rendering to the structured frontend presenter. Update export documentation.

Verification: Real invalid-timestamp regression covers failed/interrupted/
recovery-required closures; synchronous and Desktop-send failures retain durable
turn/end and cold-export identity. Structured-evidence, no-prose, presentation,
and model-context tests pass, as do frontend typecheck and affected suites.
2026-09-28 19:01:57 +08:00
SivanCola bf979b7831 Preserve error ownership across compaction entry points
Problem: Compaction entry points relabelled image, extension, persistence and
validation failures as summary errors. An expired safe-pressure operation could
replace a later error with its budget timeout and discard recovery guidance.

Root cause: The shared finish wrapper classified every returned error using the
work context state instead of the owner and identity of the failure.

Fix: Classify provider failures only at the summary request and known summary
outcomes at their owner. Normalize deadlines only when the returned error and
work budget both identify expiry. Preserve wrapped causes and apply localized
context recovery guidance last. Restore oversized protected tool-result
coverage through successful summarization without truncation.

Verification: Regression tests reproduce missing-image classification, safe
timeout followed by extension blocks, persistence identity, compress validation
and localized hard-limit recovery. Check actual summary failure typing and the
protected-result replay with intact canonical history. Run focused race tests,
pinned lint, repolint and the complete root suite; track exact-head PR CI.
2026-09-28 18:57:11 +08:00
SivanCola 1405c2a043 fix: narrow compaction recovery to the 1.x maintenance scope
Problem: The full progress and retry implementation expands the main-v2
maintenance surface beyond the reported compaction hang and truncation defects.

Root cause: The initial change coupled operation deadlines and failure handling
to new runtime, wire, persistence, and UI contracts.

Fix: Retain one shared five-minute compaction budget and typed failure recovery
without automatic lossy truncation. Remove the new observer/progress protocol,
UI, retry API, and receipt fields from the final diff. Preserve the existing
range-compression behavior and last committed projection. Keep the complete
implementation on a separate branch for independent 2.x evaluation.

Verification: Focused Agent/Control and race tests, actual SSE heartbeat parsing
under virtual time, nested/chunk budgets, queue cancellation, late completion,
partial commits, persistence reopening, pinned Go lint, repolint, and diff checks.
The complete root suite and current-head CI provide separate broad evidence.
2026-09-28 18:35:43 +08:00
SivanCola ca4fbe80aa test: complete the compaction runtime snapshot fixture
Problem: The Desktop CI frontend suite rejects the new compaction fixture.

Root cause: The snapshot omitted the required activityRevision field, which
the production-only typecheck excludes along with test sources.

Fix: Supply the initial activity revision in the orphaned-operation snapshot.

Verification: pnpm test:typecheck and the focused context-compaction-progress
test both pass. No production behavior changes.
2026-09-28 18:14:52 +08:00
SivanCola 9c3579256d fix: bound context compaction and preserve failed-session state
Problem: Near-full conversations can wait indefinitely for a summary and lose
additional model-visible context when summarization fails.

Root cause: Auxiliary response buffering hides progress, heartbeat traffic
extends transport idle timers, and overflow recovery falls back to lossy
truncation without an operation-wide deadline.

Fix: Share a cancellable five-minute compaction budget, observe content-free
generation progress, remove truncation recovery, preserve atomic save ownership,
and expose durable terminal state with session-bound compaction-only retries.
Keep initial and switched session runtime bindings available to the Stop UI.

Verification: Root Go suite, affected Desktop suites, deterministic and race
checks, frontend typecheck/transcript/browser gates, generated contracts, and
repository lint. An actual macOS Electron package passed the production
300-second heartbeat timeout, stop, switching, restart, message/draft retention,
retry-only behavior, and clean shutdown. Real providers and Windows/Linux native
packages remain unverified; an extra unpartitioned Desktop sweep timed out.
2026-09-28 18:06:36 +08:00
YHH 5d88b179b2 Merge pull request #11156 from esengine/docs/v1.39.4-notes
docs(release): prepare v1.39.4 notes
2026-09-28 00:40:41 -07:00
esengine 1132215f4a docs(release): prepare v1.39.4 notes 2026-09-28 00:39:28 -07:00
YHH d9ba90ed04 Merge pull request #11037 from esengine/dependabot/go_modules/main-v2/github.com/modelcontextprotocol/go-sdk-1.8.0
build(deps): bump github.com/modelcontextprotocol/go-sdk from 1.8.0-pre.2 to 1.8.0
2026-09-27 23:41:28 -07:00
esengine 4a09d5546b build(deps): tidy the desktop module for go-sdk 1.8.0 2026-09-27 23:26:08 -07:00
dependabot[bot] 4a5f86a0d1 build(deps): bump github.com/modelcontextprotocol/go-sdk
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.8.0-pre.2 to 1.8.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.8.0-pre.2...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-28 05:55:05 +00:00
YHH 555b96966c Merge pull request #11144 from esengine/fix/v1-chat-tui-budget
refactor(cli): move the custom status-line command out of chat_tui.go
2026-09-27 22:38:25 -07:00
esengine 790c6be08e refactor(cli): move the custom status-line command into its own file
chat_tui.go crossed its repolint file-size budget once #11114 and #11062
landed together. The status-line command runner is self-contained, so it
moves verbatim to chat_tui_statusline.go; no behaviour changes.

The freed budget is reclaimed for chat_tui.go only (file-size 4112 -> 4067,
function-size 1056 -> 1030, complexity 274 -> 271, essay 77 -> 75) and the
repo-wide limits drop by the same amounts.
2026-09-27 22:03:08 -07:00
YHH 52f465e4f5 Merge pull request #11038 from esengine/dependabot/npm_and_yarn/desktop/frontend/main-v2/npm-1x-60baa74da5
build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
2026-09-27 21:04:35 -07:00
YHH 3d129f0d6f Merge pull request #11036 from esengine/dependabot/go_modules/main-v2/go-1x-b6c41911f7
build(deps): bump the go-1x group with 5 updates
2026-09-27 21:03:13 -07:00
YHH e66c50c76b Merge pull request #10949 from boscocp/fix/v1-6692-validate-rule-tool-names
fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
2026-09-27 21:02:31 -07:00
YHH b474d12fdd Merge pull request #11076 from esengine/fix/v1-8327-deterministic-reset-fixture
test(openai): pin the body-phase reset fixture to the body phase
2026-09-27 21:01:48 -07:00
YHH b66fdb30f6 Merge pull request #11089 from esengine/fix/v1-9029-drop-into-group
fix(desktop): dropping a session on a group member row adds it to that group
2026-09-27 21:01:03 -07:00
YHH 092ab722cc Merge pull request #11085 from esengine/fix/v1-8859-needs-checks-chime
fix(desktop): ring the attention chime when a turn stops for delivery checks
2026-09-27 21:00:18 -07:00
YHH 390f996103 Merge pull request #11122 from esengine/fix/v1-11010-rotate-typed-key-in-place
fix(setup): rotate a key in place when its providers alone read the variable
2026-09-27 20:59:32 -07:00
YHH 46dd766262 Merge pull request #11093 from esengine/fix/v1-9652-review-step-cap
fix(agent): stop capping review sub-agents at eight rounds
2026-09-27 20:58:46 -07:00
YHH 4cf81e89fe Merge pull request #11062 from esengine/fix/v1-4806-mcp-retry-async
fix(cli): run MCP manager connects off the UI loop
2026-09-27 20:58:01 -07:00
YHH 63582bc7c8 Merge pull request #11092 from KHG420/codex/1x-remote-context-panel
fix(desktop): load remote session context in overview
2026-09-27 20:57:19 -07:00
YHH 54c73168c3 Merge pull request #11131 from esengine/docs/v1.39.3-notes
docs(release): prepare v1.39.3 notes
2026-09-27 19:38:39 -07:00
esengine 36c1faba14 docs(release): prepare v1.39.3 notes 2026-09-27 19:33:09 -07:00
YHH 1808ff6d63 Merge pull request #11124 from esengine/fix/v1-remote-transcript-v2
fix(serve): advertise transcript-v2 whenever the foreground can follow
2026-09-27 19:30:53 -07:00
YHH 9c3123c7a3 Merge pull request #11114 from esengine/fix/v1-hardening-0928
fix: harden project config, serve approvals, host git and sandboxed git metadata
2026-09-27 19:23:42 -07:00
esengine aecf4e6960 fix(serve): advertise transcript-v2 whenever the foreground can follow
The handshake announced transcript-v2 only while the serve's foreground
controller was bound to an exclusive (canonical) session. Opening a
historical path-backed session over SSH makes POST /resume swap the
foreground for a native-legacy controller, and from then on every
/auth/token answer dropped transcript-v2 even though GET
/transcript/follow keeps serving that controller through the legacy
projection. Any remote tab that attached or reconnected in that state
kept a capability set without transcript-v2 for its lifetime, so the
desktop refused Follow before sending a request ("transcript v2 is
required; upgrade Serve and Desktop together") and the composer refused
to send ("Transcript v2 is not synchronized"). The detached serve
survives desktop restarts, so the state persisted across upgrades.

transcript-v2 is now announced from control.TranscriptFollowAPI, the
same interface the route enforces, as transcript-outline-v1 already is.

ServeCapsToken is bumped so a desktop carrying this fix retires a live
serve started from an older binary and replaces a remote binary that
still advertises the old token; otherwise a reused serve would keep the
old handshake until it happened to exit. The same bump retires 1.38.0 to
1.38.9 binaries, which carried the previous token but predate Follow v2.

Test: TestRemoteFollowAttachesToServeWithPathBackedForeground puts a
path-backed controller in a real serve, performs the real token
handshake and follows through RemoteTranscriptFollowForTab. Before the
change it fails with "transcript v2 is required"; after it, the follow
returns a protocol-2 snapshot from the legacy backend.

Fixes #11118
Fixes #11105
2026-09-27 18:58:09 -07:00
esengine d4e11410cb perf(gitcmd): skip the driver listing for plumbing that converts no content
Every hardened invocation listed the repository's filter and merge drivers
with a second git process first, doubling the process count of host git
work. On Windows, where a process start is expensive, that pushed the
worktree suite past its 8-minute limit.

rev-parse, rev-list, merge-base, the ref commands, write-tree and
commit-tree never pass content through a filter or merge driver and never
honour submodule.recurse, so any overrides the listing produced were inert
for them. They now run without it; every subcommand that can convert
content keeps the listing and still fails closed on an unaddressable
driver name.
2026-09-27 18:57:32 -07:00
esengine 2f7809c10d fix(setup): rotate a key in place when its providers alone read the variable
Saving a new key for a provider that already had one always moved the
provider to a fresh REASONIX_CONNECTION_*_KEY slot and left the previous
value under the old variable. A name the user typed at setup (kept since
#11005) therefore went stale on the first rotation, and scripts reading
it kept seeing the old key.

RotateModelCredentialLocked rewrites the variable in place when the edit
targets the user config, the providers being saved are its only readers
there before and after the edit, no bot or remote-host setting reads it,
and the global .env already holds its value. A project reasonix.toml
cannot see the user config's readers, so its providers never rotate.
Anything else still gets a private slot.

Rollback needs the previous value back. It is written to the global .env
under a random REASONIX_ROTATION_*_KEY in the same atomic file write as
the new value, so it has exactly the protection every stored key has;
the journal records only the two names and a keyed digest of the new
value. Cleanup of a failed save and crash recovery restore the previous
value only while the variable still holds the digest this edit wrote,
then drop the backup; a published edit drops the backup and keeps the
new value.

Used by the CLI setup commit, CommitConnectionCredential and the
desktop's journalled credential edits. Desktop edits that run without a
journal keep minting private slots, since they have nothing to restore
from.

Closes #11010
2026-09-27 18:52:02 -07:00
esengine d2cb0aa894 fix(sandbox): collapse over-budget git groups to one bwrap mount
An over-budget worktrees/ or modules/ group was bound read-only as a whole,
but the exact mounts for the gitdirs inside it were still emitted first, so
planting submodule gitdirs kept growing the bubblewrap argument list. Paths
beneath a read-only tree are now left to that tree.

The serve launch-token sandbox test placed its files under /tmp outside the
write root, which bubblewrap masks with a tmpfs: the control read failed and
the denied reads passed without exercising the forbid-read mounts. The files
now live inside the write root on every platform.

The auth-disabled serve launch-token test now closes the process-wide usage
catalog serve.New starts before its REASONIX_HOME is removed, which Windows
otherwise refuses. The partial-clone test compares errors with errors.Is.
2026-09-27 18:37:19 -07:00
esengine 3cb73b3bf4 fix(sandbox): keep repository git config and hooks read-only inside the bash sandbox
The host's own git reads the workspace repository's configuration and hooks,
so a confined command that can rewrite them can make the host run a program
of its choosing. The OS sandbox now denies writes to them while leaving the
rest of .git writable.

For the repository git itself discovers from each writable root, protected
paths are: config, config.worktree, commondir and hooks/ of the gitdir and
of the common dir; config, config.worktree and commondir of every existing
linked worktree entry (new entries stay creatable); config, config.worktree,
commondir and hooks/ of every submodule gitdir under modules/. A .git file is followed to the gitdir it names
exactly as git resolves it, and the pointer file and commondir are
protected, so a repository cannot aim the protection at a decoy while git
reads something else. The .git entry, the gitdir, the common dir, every
symlink on the way to them and each writable ancestor are pinned: they
cannot be removed, renamed away or replaced by a symlink, while their own
mode and times stay writable (touch and chmod on the workspace root work).

- macOS: deny rules after every write allowance. Existing worktree and
  submodule gitdirs (up to 128 each, found without following symlinks) get
  exact rules and pins; patterns anchored at the common dir cover the rest
  and gitdirs created later, skipping refs/ and logs/ segments so a branch
  or tag named config or hooks stays writable. The profile stops growing
  past the limit.
- Linux: pinned directories become their own bind mounts and protected files
  and hook trees are bound read-only; only real directories and regular
  files are mount targets. Up to 512 gitdirs per group get exact mounts;
  past that the whole group directory is bound read-only, so planted HEAD
  files cannot overflow the argument list. Only existing paths can be
  mounted and symlinks cannot be pinned.
- Windows has no shell sandbox; the guide says so.

A protected file with another hard link refuses the confined launch with
sandbox.git_metadata_linked (ErrGitMetadataLinked), since no path rule sees
a write through the other name. Whenever a confined command's output names
a protected path, also on exit 0 (git reports some refused config writes
and exits 0), the bash result carries sandbox.git_metadata_protected.

Real-sandbox tests cover each protected path, pointer and symlink
retargeting, patterns for gitdirs created later, bounded rule size with
2000 submodule gitdirs, ordinary git work, and the git operations that now
fail or silently skip their config write.
2026-09-27 17:35:16 -07:00
esengine ec866730e0 fix(gitcmd): host git never runs repository-configured programs
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
Driver neutralization now covers every subcommand, not diff alone, and takes
driver names from git itself rather than from a separate reading of .git/config.

- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
  which git before 2.35.2 reads as a hook name), core.hooksPath,
  log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
  maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
  for checkout, switch, restore, reset, merge and read-tree (host branch
  switches and merges never update submodules, whatever the user's global
  submodule.recurse), and elsewhere only where the repository's own config
  sets it.
  GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
  repository's remote (git 2.44+); a historical patch that fails for that
  reason returns gitcmd.ErrObjectNotLocal, decided by git's own
  --missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
  carry --ignore-submodules=dirty. The subcommand is found past global
  options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
  git itself before each invocation (includes followed) and overridden empty;
  user and system scope stay live; a name -c cannot address fails closed
  (ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
  worktree, so a conditional include keyed on the new gitdir or branch is
  seen by the driver listing there rather than run by the add's checkout.
  The reset takes the worktree-mutation timeout, and a failed reset removes
  both the worktree and the branch the add created.
  worktree remove uses --force after the existing hardened clean-tree check,
  so git does not run status inside the linked worktree with its config.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
  taken from the launcher's declared env), requires an https://, ssh:// or
  git:// remote (case-insensitive; http:// and local paths are refused), and
  passes the remote and ref past -- so a leading-dash value is an operand.
- worktree git calls with extra environment keep gitcmd's environment.

- Repository identity is resolved once, when a session opens: boot runs
  gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
  before any command, the controller carries the resulting gitcmd.Repo, and a
  rebuild of the same workspace passes it on. Every later host call runs
  through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
  nested .git, a commondir file, a rewritten .git file or a damaged HEAD
  written afterwards cannot substitute another repository or its config; a
  damaged HEAD fails closed. Threaded through the TUI status line, cli
  review (resolved once before the review agent runs), the desktop changes
  panel, change detail, history, commit detail, branch list/checkout/create
  and tab branch label (the tab session's identity; a tab with no session
  yet resolves at display), worktree Inspect/Create/RollbackCreate, and the
  whole merge-back flow: the source and worktree identities are recorded in
  the merge metadata at creation (the worktree's resolved right after
  worktree add) in metadata.repo.json beside metadata.json, whose shape
  older builds decode strictly and so is unchanged; every inspect,
  auto-commit, merge and cleanup call runs against them, and a worktree
  without that file resolves them from the roots.
  InspectMerge finds the allocation root from the managed layout instead of
  asking git in the worktree.
- Repo.Verify applies Open's test to a stored identity; the identities read
  from metadata.repo.json are verified before the merge flow uses them.
- Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as
  directories and untracked nested repositories are excluded with
  :(exclude,literal) pathspecs, because add -A checks a submodule by running
  git inside it under its own config. A gitlink keeps the commit the index
  records; an untracked nested repository leaves the worktree unverifiable
  and the auto-commit refuses. The worktree state token no longer runs git
  inside a changed nested repository.
- Open accepts only a git dir belonging to the work tree that holds the
  directory: the work tree's own .git (sharing no other common dir) or a
  linked worktree's admin dir under <common>/worktrees whose gitdir file
  names that work tree's .git. A .git file or core.worktree pointing at
  another checkout is ErrNotRepository. Submodule checkouts and
  --separate-git-dir repositories opened directly are therefore treated as
  not version-controlled.

Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create, linked-worktree config on RollbackCreate),
the status readout, the launcher's non-URL remote rejection, identity
pinning after a nested .git, a commondir file and a damaged HEAD (config
origin and filter marker), Open refusing another checkout's git dir (.git
file, core.worktree, foreign admin dir), StageAll with nested repositories
named like pathspec magic or globs, metadata.json read by a strict older
decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink
with its own filter, merge-back after the worktree's .git file is pointed at
another git dir, the status line and desktop changes panel of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
2026-09-27 17:35:16 -07:00
esengine 3065db7a49 fix(serve): changes and approvals require the launch token
With authentication off (still `serve`'s default), reads stay open but every
state-changing request needs the per-launch token, as an Authorization bearer
or the cookie /auth/token issues, and otherwise refuses with 403
`launch_token_required`. The gate runs inside the host and CSRF guards, and a
configured token is honoured so a fronting proxy can inject it. Token mode also
accepts the bearer header, and the web UI explains the refusal.

The token never reaches the terminal: in both token and none modes serve writes
it to a 0600 file under the remote state directory and prints the path, or
names the --token-file of a managed launch. Runtime sandboxes and read tools
deny that directory and any registered token file.

[serve] is read from the user config only, so a project reasonix.toml cannot
choose the token, the auth mode, or trust in forwarded headers.
2026-09-27 17:35:00 -07:00
esengine ae2e9053f5 fix(mcp): project-declared MCP servers and the statusline wait for the user
MCP servers a workspace's reasonix.toml or .mcp.json declares, and the
[statusline] command, come with the checkout. Neither may start before the
user decides; this change covers those two surfaces only.

MCP servers:
- Without a recorded decision a project-declared server (stdio command or
  HTTP/SSE URL) is off. User-level servers keep following auto_start. An
  unreadable activation store and an entry of unknown provenance fail closed.
- A decision holds for the declaration it was made on: the activation row
  stores a digest of the declaration as written, the project .env values it
  expands, and the content of workspace files named directly as its command
  or arguments. A change to any of these reads as changed_since_enabled and
  keeps the server off. Files loaded indirectly, PATH-resolved interpreters
  and unpinned package launchers are outside the digest.
- Edits made through Reasonix at the user's request (desktop edit, tier,
  clear-auth, the /mcp mode and clear-auth actions) carry the decision only
  when the file afterwards holds the declaration that edit wrote and the
  .env values and named files it draws on read as in the approved state.
- MCPDecision is typed: enabled, disabled, awaiting_user_decision,
  changed_since_enabled. boot.Build emits one notice per process for each
  such server; `reasonix mcp list` shows the state; the model's refusal
  carries the code instead of "disabled in this session". The launch line
  names every env key, shows values of loader, interpreter and package-index
  variables, redacts endpoint credentials, and points to `reasonix mcp get`.
- `reasonix mcp enable/list/get` resolve the workspace a session there would
  use (nearest git root), and enable prints the approved launch line.
- Starting a project server by hand (/mcp connect, desktop reconnect or tier)
  records the decision. boot.Build, and with it CLI/TUI, `reasonix run`,
  serve, ACP, bot and desktop tabs, reads the same state, as do the /mcp
  view, desktop capabilities, controller fallbacks and `doctor --live`.
- A project .env expands only project-declared servers.

Statusline: user-level only. LoadForRoot restores the user value after the
project merge, project renders never write it, and the TUI spawns it with
NoDefaultCurrentDirectoryInExePath set.
2026-09-27 17:35:00 -07:00
xiexian 28e37a1c0a test(control): keep preset regression portable 2026-09-28 01:04:08 +08:00
xiexian 3822d577eb fix(serve): retain preset ownership through model rebuilds 2026-09-28 00:52:09 +08:00
xiexian 88751263bf fix(serve): scope session preset restore to remote frontend 2026-09-28 00:44:52 +08:00
esengine c2d10fb7e0 fix(agent): stop capping review sub-agents at eight rounds
A review, security-review or team-architect sub-agent paused after 8
tool-call rounds whenever the call did not pass max_steps, which is how
every skill-run review and most model-dispatched reviews start. The pause
message tells the user to raise max_steps, but no setting reaches this
budget, so a multi-file review could not finish at all (#9652).

Root cause: applyReviewBudget stamped a hardcoded defaultReviewMaxSteps = 8
on review-family children, and PrepareReviewSubagentContext handed the same
8 to skill runners, overriding the step budget the runner had already
resolved from the parent. Under the default uncapped parent that turned
"no limit" into 8.

Fix: the review contract keeps what it is for (the 2048-token cap per
response, the structured output format and the parent facts pack) and no
longer sets a step budget. Review children take the ordinary child budget:
an explicit max_steps wins, otherwise half the parent's cap, or uncapped
under an uncapped parent. The skill path keeps the runner's own steps.

Test: TestReviewSubagentUnderUncappedParentFinishesPastEightRounds runs a
real review task through TaskTool.Execute with a scripted 12-round child;
before the fix it fails with "sub-agent: paused after 8 tool-call rounds",
after it returns the verdict. TestReviewBudgetKeepsInheritedStepsAndBoundsOutput
(steps = 8, want 0 before) and TestReviewSkillKeepsRunnerStepBudget
("runner steps 0 reached the child as 8" before) cover the task and skill
budgets. go test ./internal/agent/ ./internal/boot/, go vet, repolint and
golangci-lint on both packages pass on Windows.
2026-09-27 09:35:34 -07:00
esengine 053136c488 fix(desktop): dropping a session on a group member row adds it to that group
A drop on a member row of a session group ran the row's reorder, so a
session dragged from outside the group stayed outside it; only the group
header accepted drop-to-group. The row drop now reads the target row's
group from the organization state: a session that is not yet a member is
set into that group, and a session already in it is reordered as before.
2026-09-27 09:15:30 -07:00
xiexian 49939444ff fix(desktop): load remote session context in overview 2026-09-27 23:57:53 +08:00
esengine 3a946c048d fix(desktop): ring the attention chime when a turn stops for delivery checks
A turn that ends with the final-readiness check failing ("need checks"),
or with another typed pause, finished silently: the runtime notification
owner played the success chime only when turn_done carried no error, and
these pauses carry the typed pause as their error. The user got no
signal that the turn was over and waiting on them.

turn_done now reads its structured outcome: final_readiness,
recovery_paused, incomplete_read and completion_uncertain - the outcomes
desktop/topic_status.go records as paused or awaiting_delivery - ring
the attention chime ("Awaiting response"). A clean finish keeps the
success chime and an ordinary failure stays silent.

Test: runtime-turn-done-chime.test.ts drives createRuntimeNotifications
with a fake AudioContext. On the base it fails ("a turn that stopped for
delivery checks must alert the user": 0 !== 1); with the fix it passes.
2026-09-27 08:47:03 -07:00
xiexian e30096d678 fix(serve): persist permission presets per remote session 2026-09-27 23:31:33 +08:00
esengine 54f4898b2c test(openai): pin the body-phase reset fixture to the body phase
TestStreamSurfacesEarlyConnResetAsInterrupt and
TestStreamDoesNotReplayAfterOutput forced a real TCP RST right after
writing the 200 head. Whether net/http observes that RST before or after
the head is up to the OS scheduler (#8327). When it lands first the
failure is a request error returned by Stream itself, not a body-phase
interruption, so the test fails on "Stream:" instead of exercising the
contract it names. On the studio line, which still retries that phase,
the same race shows up as extra server requests.

The fixture is now a scripted RoundTripper: a 200 SSE response whose body
yields the prelude and then fails with a net.OpError(ECONNRESET). The
phase is fixed, the request counter is atomic, and no socket is involved.

Test: both tests pass 500/500 on Windows. Disabling the IsConnReset branch
in streamOnce makes both fail, so the fixture still guards the contract.
2026-09-27 08:26:46 -07:00
esengine 03254360ee fix(cli): run MCP manager connects off the UI loop
Choosing Retry (or Reconnect / Enable and connect) on a server in the /mcp
manager called ConnectConfiguredMCPServer synchronously inside the Bubble
Tea Update. The initialize + tools/list handshake is bounded only by the
server's startup timeout (30s by default), so an unresponsive server froze
every keypress and redraw of the chat until the handshake gave up, which
reads as a hang. Saving a connection mode and the reconnect after MCP
OAuth made the same synchronous call.

All three now start the connect as a tea.Cmd and report back through
mcpConnectDoneMsg, whose cause picks the notice. chatTUI.mcpConnecting
records servers with a connect in flight; it lives on the chat model
rather than the manager, so closing and reopening /mcp cannot start a
second handshake. While a server is connecting, Mode, Retry, Auth, Clear
auth, Disable and Remove in the manager, and /mcp connect|remove, are
refused, so a late result cannot re-enable something the user just turned
off. If a connect still succeeds for a server that is no longer configured
(for example the config was edited meanwhile) or is disabled for the
session, the result disconnects it instead of keeping it live. The result
refreshes the manager without moving it to another page.

Test (internal/cli/mcp_manager_connect_test.go, gated HTTP MCP fake):
- TestMCPManagerRetryDoesNotBlockTheUILoop: on main-v2 applyMCPAction held
  the loop for the full startup timeout (3.0s with a 3s timeout) and
  failed. Now it returns at once; after closing and reopening /mcp a
  second Retry is refused; Disable and Remove are refused while in flight;
  the successful result clears the marker, leaves the manager's stage, and
  a Disable afterwards takes effect.
- TestMCPConnectFinishingAfterRemovalIsDropped: the entry is removed from
  reasonix.toml while initialize is held; the finished connect is
  disconnected. Without the completion check it stayed live (fails).
- TestApplyMCPModeRecordsPluginConnectFailure (moved from mcp_test.go)
  now drives the returned command and still records the host failure.
2026-09-27 07:34:09 -07:00
esengine 35fc8b746a build(deps): tidy the desktop module for the go-1x bumps
desktop/ replaces reasonix with the parent module, so the root bumps raise
its graph too and its go test stops with "updates to go.mod needed".
go mod tidy in desktop/ moves only the versions the root already requires.
2026-09-27 07:22:11 -07:00
esengine befb5e258c build(deps): sync desktop/pnpm-lock.yaml with the npm-1x bumps
Dependabot updated desktop/frontend/package.json but not the workspace
lockfile at desktop/pnpm-lock.yaml, so every frozen install failed with
ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 10.34.5 --lockfile-only;
only the four bumped packages and their own dependencies move.
2026-09-27 07:10:35 -07:00
Adalberto Pereira 8e9f7cfed0 fix(permission): name an unclosed subject and stop widening allow rules / 提示未闭合括号并避免放宽 allow 规则
A rule missing its closing parenthesis is now reported as that typo and
repaired instead of being wrapped in a second Bash(...) that parses but
still matches nothing, and a bare command on the allow list is suggested
in its exact form, since the prefix form would also grant every argument.
2026-09-27 10:58:48 -03:00
YHH 96297a2146 Merge pull request #11047 from KHG420/codex/1x-acp-skill-raw
fix(acp): preserve typed skill input in 1.x turns
2026-09-27 06:41:47 -07:00
YHH 48b340a877 Merge pull request #11051 from esengine/fix/v1-release-without-website
ci(release): publish 1.x without deploying or verifying the website
2026-09-27 06:16:22 -07:00
esengine d67f2835ac ci(release): publish 1.x without deploying or verifying the website
The website is released from its own branch and renders the changelog from
that branch's catalog, so the 1.x publication could dispatch Pages and still
fail on a changelog page it does not own (v1.39.2: "public changelog returned
HTTP 404" after every product surface had shipped).

- Postflight now verifies tags, CLI and Desktop releases, npm, the Desktop
  Stable manifest on dl.reasonix.io and the Homebrew cask, attaches
  release-event.json, and completes. sync-release-site.sh becomes
  reconcile-release-publication.sh without the Pages dispatch/watch stages.
- The stable manifest check stays: it reads dl.reasonix.io/latest/latest.json,
  the Desktop updater pointer on R2, not the website. The Homebrew check moves
  out of the site verifier and reads the tap through the contents API.
- Drop the homepage/changelog/hydrated-DOM probes, observe-release-site.sh,
  release-site-fetch, and the pages.yml dispatches in release-stable.yml and
  release-desktop.yml together with the actions: write they needed.
- Ledger: the site fragment becomes a pointer fragment (stableManifest,
  homebrew); homepage and changelog surfaces are gone.
2026-09-27 05:55:11 -07:00
xiexian 6512f0db40 fix(acp): exclude resolved 1.x skills from routing 2026-09-27 20:35:52 +08:00
dependabot[bot] a0ac937c65 build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
Bumps the npm-1x group in /desktop/frontend with 4 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [katex](https://github.com/KaTeX/KaTeX), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-virtual` from 3.14.11 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/[email protected]/packages/react-virtual)

Updates `katex` from 0.18.7 to 0.18.9
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.18.7...v0.18.9)

Updates `@typescript-eslint/parser` from 8.70.0 to 8.70.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.1/packages/parser)

Updates `tsx` from 4.23.11 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.15)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: katex
  dependency-version: 0.18.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:54:01 +00:00
dependabot[bot] 15a1f262fb build(deps): bump the go-1x group with 5 updates
Bumps the go-1x group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) | `2.0.9` | `2.0.10` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.0` | `4.10.2` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.4` | `1.18.7` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.29` | `0.0.30` |
| [mvdan.cc/sh/v3](https://github.com/mvdan/sh) | `3.14.0` | `3.14.1` |


Updates `charm.land/bubbletea/v2` from 2.0.9 to 2.0.10
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.9...v2.0.10)

Updates `github.com/bmatcuk/doublestar/v4` from 4.10.0 to 4.10.2
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.2)

Updates `github.com/klauspost/compress` from 1.18.4 to 1.18.7
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.4...v1.18.7)

Updates `github.com/mattn/go-runewidth` from 0.0.29 to 0.0.30
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.29...v0.0.30)

Updates `mvdan.cc/sh/v3` from 3.14.0 to 3.14.1
- [Release notes](https://github.com/mvdan/sh/releases)
- [Changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mvdan/sh/compare/v3.14.0...v3.14.1)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: mvdan.cc/sh/v3
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:53:34 +00:00
YHH 7574f8b4c9 Merge pull request #11031 from esengine/docs/v1.39.2-notes
docs(release): prepare v1.39.2 notes
2026-09-27 04:39:01 -07:00
esengine d945284cec docs(release): prepare v1.39.2 notes
Bilingual Stable notes for everything merged to main-v2 since v1.39.1: unopenable and unarchivable sessions, live streaming on legacy-backed sessions, typed skill invocations, the setup API key variable name, and code-page output on Chinese Windows. CI-only changes (#10863, #10870, #10983) are left out.
2026-09-27 04:37:09 -07:00
xiexian c55c6bee78 fix(acp): preserve raw text across slash prompt expansion 2026-09-27 19:30:06 +08:00
YHH 2a3855f16a Merge pull request #11023 from KHG420/codex/1x-inline-skill-raw
fix(skill): route 1.x inline skills from raw user input
2026-09-27 04:20:36 -07:00
YHH 6d0c756228 Merge pull request #11022 from esengine/fix/v1-archived-row-stays-listed
fix(desktop): hide a historical row once any workspace has adopted its source
2026-09-27 03:41:03 -07:00
esengine dd9b441f99 fix(desktop): hide a historical row once any workspace has adopted its source
The sidebar lists a legacy transcript under the scope its sidecar names, and
the persist observer indexes a save under the global fallback when the sidecar
names none. Archive and import assign the owning workspace from the store the
file physically lives in. When the two disagree, the adopted source was only
filtered from the workspace that owns the mapping, so the row stayed listed in
the other one: archiving it "succeeded" again and again, and pin, rename and
open resolved to the archived conversation and were refused with
"Restore this session before ...".

Adoption is a property of the source identity, so the sidebar's adopted-row
filter and the lazy page's path exclusions now consider every mapping, as the
history listing already did. A head-keyed mapping owned by another workspace
that cannot prove a path alias still does not disable the lazy adapter here.
2026-09-27 03:21:47 -07:00
YHH acf3bfdcbc Merge pull request #11003 from KHG420/codex/1x-worktree-git-write-hint
fix(sandbox): guide worktree Git metadata write approval
2026-09-27 03:00:13 -07:00
xiexian 96b296cd90 test(skill): reject slash mentions as implicit invocations 2026-09-27 17:55:14 +08:00
xiexian b18f84ee22 fix(skill): route inline invocations from raw user input 2026-09-27 17:38:47 +08:00
YHH 29c8349c3a Merge pull request #11007 from esengine/fix/v1-11004-inline-skill-pin
fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
2026-09-27 02:26:16 -07:00
YHH 51fe2e9255 Merge pull request #11005 from esengine/fix/v1-11002-api-key-env-name
fix(setup): keep an API key variable name the user types
2026-09-27 02:17:10 -07:00
xiexian 8cf5b64418 test(sandbox): compare JSON-escaped worktree hints on Windows 2026-09-27 17:16:21 +08:00
esengine 463840813f fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
A bare "/name" line or a lone skill chip reached the provider as the raw
rendered skill body in plain user prose: no <skill-pin> sentinel, and on the
structured (composer) path the typed text trailed as an unlabeled paragraph
instead of an Arguments line. The model-invoked path (run_skill,
read_only_skill, read_skill) already sent the pinned form, so one skill had
two payload shapes depending on who invoked it.

Every invocation site now renders through skill.RenderInvocation: the
string submit path, the structured path, RunSkill (ACP prompts) and the
slash_command tool. A lone inline chip takes the typed text as its
arguments, matching "/name task"; several inline chips, or chips mixed with
subagents, keep appending the shared text once. Nothing moves into the
cache-stable prefix. Store.Render is removed and Render is unexported, so the
pinned form is the only one an invocation can take.

An effect test through the real Build asserts the pinned payload at the
provider boundary for the bare and argument-carrying forms of both paths.

Fixes #11004
2026-09-27 02:09:43 -07:00
esengine c04f7dd247 fix(setup): keep an API key variable name the user types
`reasonix setup` asked for the API key variable name, then saved every
new key under a freshly allocated REASONIX_CONNECTION_*_KEY slot, so a
typed name was dropped from config.toml and .env without a word.

A typed name is now kept when saving under it changes nothing another
reader resolves: no other provider, bot or remote-host setting reads it,
the credential store holds no value or tombstone for it, and the process
environment does not already set it (compared case-insensitively on
Windows). The wizard checks that as soon as the name is typed, says what
holds it, and asks again; Enter still means a private slot. The commit
repeats the check under the edit locks, so a name claimed in between
refuses the save with config.ErrCredentialKeyInUse instead of
overwriting it.

The staging journal records a keyed digest of each staged value, and
cleanup and crash recovery remove a slot only while the store still holds
that value, so a named slot another writer took over survives recovery.
Whether the config still references a slot is read from the parsed
config instead of a substring match, so API_KEY is no longer "referenced"
by DEEPSEEK_API_KEY and left behind as an orphan.

The prompt no longer shows a bracketed default it would not use.

Fixes #11002
2026-09-27 01:59:35 -07:00
xiexian a44319982f fix(sandbox): validate and narrow worktree metadata hints 2026-09-27 16:56:29 +08:00
YHH a8bea84e68 Merge pull request #10914 from esengine/fix/remote-prompt-card
fix(desktop): a remote Ask shows once and can be answered
2026-09-27 01:30:14 -07:00
YHH f457bbbd61 Merge pull request #10883 from esengine/fix/archive-recovered-copies
fix(desktop): archiving a recovered legacy conversation archives all its copies
2026-09-27 01:21:53 -07:00
YHH 3007c94084 Merge pull request #10997 from KHG420/codex/1x-history-reload-identity
fix(desktop): accept refreshed history after cache eviction
2026-09-27 01:12:16 -07:00
xiexian c09dbf56b9 fix(sandbox): include common Git metadata in retry hint 2026-09-27 16:08:19 +08:00
xiexian 26984605be fix(sandbox): guide worktree Git metadata write approval 2026-09-27 16:04:14 +08:00
esengine 85759d074c fix(desktop): archiving a recovered legacy conversation archives all its copies
A 1.38 write conflict saved one recovery copy per writer, each with the
recovered flag and the same parent in its metadata. The desktop catalog
runs metadata-only, which never folds them, so every copy is its own
sidebar row with the same title and the "legacy" and "recovered" badges.
Archiving a row archived exactly that copy and the next one took its
place, which reads as "cannot archive".

Archiving a recovered legacy source now also archives the other recovered
copies in its directory whose metadata names the same parent, each through
the same source archive path. Unrelated conversations are untouched, and
everything lands in the archive where it can be restored.

Reproduced on Windows with 1.39.1 and two copies written by
SaveConflictRecoveryBranch: one archive left the second row; with the fix
both rows go and both appear under Archived.
2026-09-27 00:59:53 -07:00
xiexian e47c32200c test(desktop): type history reload fixture for CI 2026-09-27 15:06:52 +08:00
xiexian 861a7b8d12 fix(desktop): accept refreshed older history after cache eviction 2026-09-27 14:47:40 +08:00
YHH 32107d7bb6 Merge pull request #10983 from esengine/fix/v1-release-notes-authors
feat(release-notes): credit pull request authors and issue fixers in rendered notes
2026-09-26 23:37:37 -07:00
esengine d5c9f2d739 feat(release-notes): credit pull request authors and issue fixers in rendered notes
Rendering now resolves every refs number through the GitHub GraphQL API.
A pull request gains "by @author". An issue gains "fixed in #M by @author"
for each merged pull request GitHub links as closing it (the ClosedEvent
closer and closedByPullRequestsReferences; mentions are not fixes); an
issue fixed by a direct push stays plain. Bot authors are never credited.
A number that names a discussion or nothing stays plain with a warning.

The closing Contributors / 贡献者 section lists exactly the credited
humans, once each, in first-appearance order. It replaces the old Thanks
section, which linked the catalog's contributors (github-actions[bot]
included) without mentioning them; that field no longer feeds the notes.

Lookups are cached per run, bounded in concurrency, and retried with
backoff on 5xx, rate limits and dropped connections. A failure that
persists, or an authorisation failure, renders the ref as its plain link
and fails the command with a release_credits.* code.

Because rendering now reads GitHub, the seal job renders the notes once
into the candidate payload (evidence/release-notes.md, recorded as
notes.renderedPath and bound to renderedSha256). Promotion publishes
those sealed bytes with no network call, so a rename, a newly linked fix
or an outage after sealing cannot change or block the release. Candidates
sealed before this carry no renderedPath and render offline as before.

Every job that renders, and every caller of a reusable workflow that
does, gains issues: read and pull-requests: read.
2026-09-26 23:24:50 -07:00
YHH 1a74c7450f Merge pull request #10979 from KHG420/codex/fix-1x-sidebar-loading
fix(desktop): show initial project loading state on 1.x
2026-09-26 22:59:20 -07:00
xiexian 947ce21896 chore(desktop): stay within project tree line budget 2026-09-27 13:30:40 +08:00
xiexian 872ad88677 fix(desktop): settle project shell loading after read failure 2026-09-27 13:21:07 +08:00
YHH 4512a84cbb Merge pull request #10977 from KHG420/codex/fix-reopened-session-index
fix(session): extend cached index after reopening writer
2026-09-26 22:17:13 -07:00
YHH 9424d402fb Merge pull request #10973 from esengine/fix/v1-10968-stream-generation
fix(transcript): stream live deltas for sessions on the legacy projection
2026-09-26 22:00:21 -07:00
esengine 2dfa1ca44c fix(transcript): stream live deltas for sessions on the legacy projection
A session opened from a legacy topic path follows its transcript through
the legacy projection (storageBackend "legacy"). There every turn event,
text and reasoning deltas included, takes the next ledger sequence, so
each follow change advanced commitSeq by one but carried no firstSeq.
The desktop follow client treats a change without firstSeq as a display
frame that must ride the current cut, rejected the very first change of
the turn as "transcript frame cut mismatch", dropped the subscription
and re-baselined once a second. No delta reached the reducer, so neither
thinking nor answer streamed and the reply appeared whole at turn end.

The stream_attempt commit on that path also asked for a reset, because
the legacy path never recorded a result sequence. Recording the message
event's sequence covers rounds with text or reasoning, but a tool-call-
only round and an interrupted round publish no message event at all.

Projection.Apply now publishes the sequence each envelope occupies as
the change's firstSeq. A commit settles on the recorded message sequence
or, when the round had no message event, on its own sequence: the agent
emits the commit only after the round's record is durably appended, so
that sequence is already covered. The entry is dropped at the commit so
the map stays bounded. ApplyFrame (the exclusive runtime path) is
unchanged.

Test: TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts
opens a legacy topic through StartTopicActivation on a real App with a
scripted streaming provider, follows through TranscriptFollowForTab and
applies the renderer's acceptance rules (mirroring
transcriptFollowClient.ts validate: coverage, sampling, resultKind,
records-based result identity with its 192 cap). Subtests run a
streamed reply, a tool-call-only round and a cancelled round, each
followed by a streamed reply.
- main-v2: all three fail with "frame_cut_mismatch at revision 3".
- message-sequence fix only: tool-call-only and cancelled rounds fail
  with "revision_gap" at the commit.
- this change: all pass, every delta delivered with no re-baseline.
Replaying captured follow responses through the real
TranscriptFollowClient accepted 0 deltas before and all of them after.

Fixes #10968
2026-09-26 21:32:08 -07:00
YHH e5795e5e0f chore: extract website and platform from product tree
Move the website and Cloudflare platform services to their dedicated protected branches. Keep main-v2 focused on the product and retain only the product-side release integration contract.
2026-09-27 04:29:09 +00:00
xiexian 86db9b4df3 fix(session): extend cached index after reopening writer 2026-09-27 12:22:14 +08:00
xiexian 8829515d5e style(desktop): keep project tree within lint budget 2026-09-27 12:19:16 +08:00
xiexian 8b2504fb6b fix(desktop): show loading state until first project tree snapshot 2026-09-27 12:04:12 +08:00
Adalberto Pereira 42503a4d36 fix(permission): ask unicode.IsSpace on both sides of the invariant / 检查与其不变量使用同一套空白判断
The check rejected " " and "\t" while the invariant test that justifies it
pinned " \t\r\n", so a tool name broken across lines was unmatchable but not
reported, and the two could drift further apart with neither failing. Both now
ask unicode.IsSpace, which also covers the non-breaking space a settings panel
can paste in.
2026-09-26 17:46:53 -03:00
Adalberto Pereira 598ecbf1b2 fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
A permission rule is "ToolName" or "ToolName(glob)", but the rules people
write are bare shell commands: deny = ["rm", "git reset", "git push --force"].
ParseRule treats only an empty tool name as malformed, so those entries parse,
install, and then match nothing — the author believes a deny guards them and it
never fires. ParseRule's own doc comment states the intent ("so the caller can
warn rather than silently install a rule that matches nothing"); no caller did.

UnmatchableRules reports a rule whose tool name contains whitespace and renders
the Bash(...) rewrite that expresses the intent. Whitespace is an exact test
rather than a guess: a tool name is an identifier and never contains any, which
is now pinned by TestNoBuiltinToolNameHasWhitespace. That keeps the warning off
a plugin or MCP tool that is simply not registered yet — the per-run registry is
still filling when the policy is built, so membership cannot be tested there
without accusing rules that work.

The notice goes to the event sink rather than Config.LoadWarnings because only
the desktop reads those, and a rule typed into config.toml has to reach a CLI
user too.
2026-09-26 17:40:45 -03:00
esengine d730d8c8f8 fix(desktop): a remote Ask shows once and can be answered
#10865: in an SSH remote workspace an Ask showed twice and neither card
answered. The remote surface's card was disabled with "This card's exact
request identity is unavailable", and the footer's copy looked live but
its Submit went nowhere.

- RemoteSessionSurface required a runtimeEpoch on every prompt. A CLI
  serve never binds desktop routing metadata, so its prompts carry none;
  the serve and the kernel fence on the epoch only when they have one,
  and the desktop bridge forwards it unchanged. The check now stops at
  the host, session, generation and turn it can actually expect.
- The shell's decision footer also docked the remote prompt from shared
  state, but its answer path targets a local session a remote tab does
  not have, so it was cancelled as not ready. On a remote tab the footer
  leaves approvals, asks, MCP interactions and extension forms to the
  remote surface; shell decisions (clear context, close, conflicts) stay.

Reproduced on Windows with 1.39.1 against a Linux container over SSH
(same two cards, same error); with this build one card appears, both
questions answer and the turn continues.
2026-09-26 10:38:56 -07:00
824 changed files with 29165 additions and 40576 deletions

No files matched your search

-2
View File
@@ -10,9 +10,7 @@ on:
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
+4 -34
View File
@@ -1299,7 +1299,7 @@ jobs:
if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
runs-on: windows-latest
# Independent runners prevent valid groups from consuming each other's
# step deadline. Go retains its default per-package ten-minute timeout.
# step deadline. Each group runs with go -timeout=25m (driver testArgs).
timeout-minutes: 45
strategy:
fail-fast: false
@@ -1636,16 +1636,13 @@ jobs:
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "macos-15-intel" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-candidate.yml \
.github/workflows/release-candidate-verify.yml \
.github/workflows/release-promote.yml \
.github/workflows/release-verify.yml \
.github/workflows/release-site-recovery.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/pages.yml \
.github/workflows/apple-notary-log.yml \
.github/workflows/macos-signing-check.yml \
.github/workflows/release-verify-issues.yml \
@@ -1655,6 +1652,7 @@ jobs:
scripts/verify-release-artifact-archive.test.mjs \
scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
scripts/release-cli-freeze.test.mjs \
scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
scripts/desktop-windows-go-tests.test.mjs \
npm/publish.test.mjs
@@ -1669,14 +1667,14 @@ jobs:
node scripts/check-single-release-public-contract.mjs
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
# Required check for the root-module and site jobs the per-OS `test` legs do
# required checks for root-module jobs the per-OS `test` legs do
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
# always report a result: `success` here means "did not fail", not "validated
# this diff" — the same trade-off the required `test` legs already make. Do
# not add govulncheck: it sets continue-on-error, so needs.*.result is
# `success` even when its steps fail and the assertion would be a tautology.
root:
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
needs: [changes, windows-control, windows-isolated, sdk, coverage]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
@@ -1684,12 +1682,10 @@ jobs:
env:
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
CONTROL_RESULT: ${{ needs.windows-control.result }}
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
SDK_RESULT: ${{ needs.sdk.result }}
SITE_RESULT: ${{ needs.site.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
test "$CHANGES_RESULT" = success
@@ -1697,7 +1693,6 @@ jobs:
test "$CONTROL_RESULT" = success
test "$SDK_RESULT" = success
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
# frontend unit coverage can run once in desktop-frontend without weakening
@@ -1731,31 +1726,6 @@ jobs:
test "$FRONTEND_RESULT" = skipped
fi
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
# The notes_only term matches every other gated job here. Without it a
# release-notes-only push runs this job while the root aggregate expects the
# house-style skip, which would leave main-v2 permanently red. A pure
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
site:
needs: changes
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .node-version
- name: test
run: npm test
govulncheck:
needs: changes
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
@@ -1,46 +0,0 @@
name: Deploy accounts worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
-168
View File
@@ -1,168 +0,0 @@
name: Deploy crash worker
on:
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Apply and verify diagnostics v2 D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Apply dashboard performance indexes
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:dashboard-indexes
- name: Apply registry performance indexes and install rollup
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:registry-indexes
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler deploy
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
# sentinel can push alerts (see runIngestSentinel). Managed here so
# nobody needs local Cloudflare credentials; when the repo secret is
# unset the sentinel stays log-only.
- name: Sync alert webhook secret
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
run: |
if [ -z "$ALERT_WEBHOOK" ]; then
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
echo "Firebase crash data migration must run from main-v2."
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
-36
View File
@@ -1,36 +0,0 @@
name: Deploy forum worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
npx wrangler deploy
-76
View File
@@ -1,76 +0,0 @@
name: Deploy site
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
on:
workflow_dispatch:
inputs:
release_version:
description: "Optional release version that owns this deployment"
required: false
default: ""
type: string
release_request:
description: "Release workflow correlation ID (optional)"
required: false
default: ""
type: string
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: inputs.release_version != ''
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
if: inputs.release_version != ''
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: inputs.release_version != ''
with:
go-version-file: go.mod
cache: false
- name: Recheck release ownership immediately before deployment
if: inputs.release_version != ''
env:
VERSION: ${{ inputs.release_version }}
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
- id: deploy
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
-106
View File
@@ -1,106 +0,0 @@
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
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 [[ ! "$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"
source_sha="$(git merge-base origin/main-v2 HEAD)"
else
git checkout -b "$branch"
source_sha="$(git rev-parse HEAD)"
fi
git merge-base --is-ancestor "$source_sha" origin/main-v2
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# The first preparation fixes the product range. Reruns update the
# draft without silently absorbing unrelated main-v2 merges.
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
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
{
echo "## Review draft for v${VERSION#v}"
echo
cat /tmp/release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Open or update the review PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "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."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
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; 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
+18 -8
View File
@@ -194,6 +194,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -210,6 +212,7 @@ jobs:
env:
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
run: |
node scripts/release-notes.mjs validate
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
@@ -259,6 +262,8 @@ jobs:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
@@ -285,6 +290,8 @@ jobs:
attestations: write
contents: read
id-token: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -338,6 +345,16 @@ jobs:
test -s payload/evidence/windows-amd64.json
test -s payload/evidence/windows-arm64.json
test -s payload/evidence/macos-universal-intel.json
# Rendered once and sealed: promotion publishes these bytes and never
# reads GitHub again, so a later rename or outage cannot change them.
- name: Render reviewed notes into the sealed payload
env:
GH_TOKEN: ${{ github.token }}
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
git worktree remove "$worktree"
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: payload/**/*
@@ -359,13 +376,6 @@ jobs:
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Render reviewed notes from the immutable product
run: |
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
git worktree remove "$worktree"
- name: Seal candidate record
env:
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
@@ -385,7 +395,7 @@ jobs:
RELEASE_ACCEPTANCE_JSON: >-
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
run: |
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
+4 -9
View File
@@ -1084,6 +1084,8 @@ jobs:
permissions:
contents: write
actions: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# Approved orchestrators have already passed the matching GitHub environment.
# Direct prereleases and manual Stable recovery pass release-gate above. The
# Certum signing completes before either platform bundle reaches publication.
@@ -1200,6 +1202,8 @@ jobs:
# GitHub release as usual.
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
- name: Revalidate approved release ref
@@ -1292,7 +1296,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
# GitHub release, so it must fail closed before attempting publication.
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
@@ -1818,11 +1821,3 @@ jobs:
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+2 -9
View File
@@ -4,16 +4,9 @@ on:
pull_request:
paths:
- "release-notes/**"
- "scripts/generate-release-notes.mjs"
- "scripts/release-notes*.mjs"
- "scripts/release-credits*.mjs"
- "scripts/release-event.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "site/src/data/releases.ts"
- "site/src/styles/changelog.css"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
@@ -27,4 +20,4 @@ jobs:
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
+3 -1
View File
@@ -221,6 +221,7 @@ jobs:
if: ${{ inputs.candidate_artifact_name == '' }}
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
- name: Publish or recover prepared npm packages
@@ -228,10 +229,11 @@ jobs:
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
if: ${{ inputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
env:
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
+17 -8
View File
@@ -23,6 +23,8 @@ permissions:
actions: write
attestations: read
contents: write
issues: read # granted to the nested release workflows, which can render notes
pull-requests: read
jobs:
preflight:
@@ -146,12 +148,19 @@ jobs:
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Render and bind reviewed release notes
# A candidate sealed with its notes publishes those bytes without touching
# the network. One sealed before that renders offline from its own source.
- name: Bind reviewed release notes
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
if [ -n "$sealed" ]; then
cp "payload/$sealed" release-notes.md
else
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
fi
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
@@ -306,7 +315,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher
@@ -326,7 +334,7 @@ jobs:
with:
go-version-file: go.mod
cache: false
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
- name: Reconcile public artifacts, release event, and durable ledger
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
@@ -334,7 +342,8 @@ jobs:
RELEASE_OPERATION: ${{ inputs.operation }}
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
run: bash scripts/sync-release-site.sh
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: bash scripts/reconcile-release-publication.sh
- name: Upload publication ledger
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
-111
View File
@@ -1,111 +0,0 @@
name: Recover release site
run-name: Recover site v${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Already-published stable version; no builds or package publication"
required: true
type: string
concurrency:
group: stable-release-publication
cancel-in-progress: false
permissions:
contents: read
jobs:
preflight:
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.identity.outputs.source_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Validate executable contracts and public access before approval
env:
VERSION: ${{ inputs.version }}
run: |
bash scripts/validate-release-control-plane.sh
bash scripts/check-release-public-access.sh "$VERSION"
- name: Bind existing public tags to protected history
id: identity
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
git merge-base --is-ancestor "$source_sha" origin/main-v2
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
- name: Re-read public package identities before approval
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
run: bash scripts/verify-stable-release-artifacts.sh
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/site-recovery-preflight.json
retention-days: 90
if-no-files-found: error
sync:
needs: preflight
environment: release
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
actions: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Recover only the website and verify public state again
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_SITE_RECOVERY_ONLY: "true"
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
run: bash scripts/sync-release-site.sh
- name: Preserve successful or incomplete recovery evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/publication-ledger.json
retention-days: 90
if-no-files-found: error
+7 -5
View File
@@ -55,6 +55,8 @@ concurrency:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
jobs:
preflight:
@@ -63,6 +65,8 @@ jobs:
permissions:
actions: read
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
@@ -111,6 +115,8 @@ jobs:
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
env:
GH_TOKEN: ${{ github.token }}
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,
# which can predate its reviewed release-note entry. Carry the exact file
@@ -226,7 +232,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher to succeed
@@ -273,6 +278,7 @@ jobs:
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: bash scripts/verify-stable-release-artifacts.sh
- name: Publish exact Stable release record
env:
@@ -303,7 +309,3 @@ jobs:
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
+2 -1
View File
@@ -35,5 +35,6 @@ jobs:
RELEASE_OPERATION: recover
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
VERIFY_HOMEPAGE: "true"
VERIFY_PUBLIC_POINTERS: "true"
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: bash scripts/verify-stable-release-artifacts.sh
+19 -3
View File
@@ -212,6 +212,10 @@ jobs:
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# 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.
@@ -274,6 +278,7 @@ jobs:
if: ${{ !inputs.orchestrated }}
env:
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
env:
@@ -328,10 +333,10 @@ jobs:
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
with:
version: '~> v2'
args: release --clean
args: release --clean${{ vars.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' && secrets.HOMEBREW_TAP_TOKEN || '' }}
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
# though checkout is on the release tag. Pin GoReleaser explicitly, and
# avoid ambiguity from the three release tags sharing one commit.
@@ -354,7 +359,7 @@ jobs:
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
- name: Publish prepared Homebrew cask
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }}
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
@@ -370,6 +375,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
@@ -473,6 +479,11 @@ jobs:
exit 0
fi
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
echo "CLI publication is frozen; the $channel pointer stays where it is and $TAG keeps only its immutable record"
exit 0
fi
current_tag=""
pointer_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
@@ -607,6 +618,7 @@ jobs:
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ needs.resolve.outputs.tag }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
run: |
set -euo pipefail
case "$TAG" in
@@ -620,6 +632,10 @@ jobs:
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
echo "CLI publication is frozen; npm latest belongs to another line, only the npm-v$version tag is required"
exit 0
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
+5 -3
View File
@@ -52,14 +52,16 @@ lint-cross:
test:
go test ./...
# The desktop suite needs ~25m locally, past go's default 10m test alarm
# (the TaggedHistory migrations alone are ~389s), so these targets pin a wider one.
desktop-test:
cd desktop && go test .
cd desktop && go test -timeout=25m .
desktop-test-short:
cd desktop && go test -short .
cd desktop && go test -short -timeout=25m .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
cd desktop && go test -count=1 -timeout=25m -json . | python3 ../scripts/desktop-test-times.py
sdk-test:
cd sdk/go && go test ./...
+1 -2
View File
@@ -100,8 +100,7 @@ for the latest desktop build.
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
| Linux | `.deb` or `.tar.gz` | x64 |
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
Windows installers are code-signed with a Certum code-signing certificate.
### Path C: VS Code extension
+1 -2
View File
@@ -92,8 +92,7 @@ brew install esengine/reasonix/reasonix # macOS
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
| Linux | `.deb` 或 `.tar.gz` | x64 |
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
[SignPath 基金会](https://signpath.org/) 免费提供。
Windows 安装器使用 Certum 代码签名证书签名。
### 路径 C:VS Code 扩展
+53 -6
View File
@@ -56,9 +56,9 @@ Supported boundaries include:
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- HTTP `serve` protections, including the launch token every state-changing
request requires when authentication is off, localhost binding assumptions,
JSON-only state-changing requests, and CORS restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
- Project configuration files intentionally loaded from the current workspace.
- The user's own configuration under the Reasonix home directory.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- MCP servers, language servers, hooks, and slash commands installed, enabled,
or approved by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## Project Configuration
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
repository is not a decision to trust its author, so these files may narrow
what the user configured and never widen it.
| Setting | What a workspace file may do |
| --- | --- |
| `sandbox.bash` | Move `off` to `enforce`, never back |
| `sandbox.network` | Move `true` to `false`, never back |
| `sandbox.forbid_read` | Add to the user's list |
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
| `desktop.default_tool_approval_mode` | Nothing |
| `[network]` proxy settings, `[bot]` | Nothing |
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
An "always allow" answer, or extra write access, granted in a workspace is
stored under the Reasonix home, not in the workspace.
Programs a workspace names for the host to run stay off until the user
approves them for that workspace:
- Hooks, language server commands, `tools.search.rg_path`,
`tools.shell.path` and `[browser]` launch settings are approved with
`reasonix trust`, or for hooks from the desktop hooks settings.
- An approval is stored under the Reasonix home with a digest of the
declaration and of the workspace files it names; any change needs approval
again. Approvals and grants belong to one workspace folder.
- Hooks and language servers are checked against their approval again before
each start, and refused if a file they name changed, appeared or now
resolves elsewhere.
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
`browser.chrome_path`) may not point into the workspace, an `allow_write`
root, a temporary directory or a toolchain cache the bash jail leaves
writable, and may not climb with `..`; its approval covers the program
file's content.
- Without an interactive frontend they stay off.
- MCP servers a workspace declares stay off until the user enables that
declaration for that workspace.
A project value that is not applied is reported as a load warning with the
reason. A way for workspace files to widen any of the above without the
user's approval is a security issue.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
+1
View File
@@ -20,6 +20,7 @@ func writeTaskVerify(t *testing.T, taskDir, script string) {
}
func TestGradeCheckpointsFindsEarliestCorrectState(t *testing.T) {
requireRealBash(t)
taskDir := filepath.Join(t.TempDir(), "task")
writeTaskVerify(t, taskDir, "#!/usr/bin/env bash\ngrep -q done answer.txt\n")
+23 -4
View File
@@ -1,11 +1,13 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const corpusDir = "../../benchmarks/e2e"
@@ -56,6 +58,24 @@ func gradeSeed(t *testing.T, work string) error {
return cmd.Run()
}
// requireRealBash skips hosts whose bash cannot actually run commands. On
// Windows exec.LookPath("bash") happily resolves to the System32 WSL launcher,
// so a LookPath guard passes while every grade fails inside a distro that may
// not even carry /bin/bash. The graders are POSIX fixtures; probing mirrors
// what sandbox's own shell resolution does before trusting a bash. Timeout
// bounded in case the stub blocks on an install prompt.
func requireRealBash(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash unavailable; the graders are POSIX shell fixtures")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "bash", "-c", "true").Run(); err != nil {
t.Skipf("bash cannot run commands (%v); the graders need a working POSIX shell", err)
}
}
// forbiddenArtifact names, per task, a file whose mere existence is the
// documented way to fake that task's missing piece. Probing it keeps the
// absence checks honest; inferring intent from a missing manifest does not,
@@ -157,10 +177,9 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// The graders are POSIX shell and python3 fixtures, and the suite they
// belong to only ever runs on POSIX CI. Where either is missing this
// checks the host, not the corpus.
for _, bin := range []string{"bash", "python3"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("%s unavailable; the no-solution graders need a POSIX shell and python3", bin)
}
requireRealBash(t)
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable; the no-solution graders need a POSIX shell and python3")
}
tasks, err := loadTasks(corpusDir)
if err != nil {
+2
View File
@@ -11,6 +11,7 @@ import (
// produced. For an exploration task the grader's "want X, got Y" line is the
// entire finding — a bare false would leave every failure indistinguishable.
func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
requireRealBash(t)
taskDir := t.TempDir()
verify := "#!/usr/bin/env bash\necho \"answer.txt normalized to 'askrigg', want 'gorsefen'\" >&2\nexit 1\n"
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
@@ -27,6 +28,7 @@ func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
}
func TestGradeVerboseStaysQuietWhenTheTaskPasses(t *testing.T) {
requireRealBash(t)
taskDir := t.TempDir()
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/usr/bin/env bash\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
+18 -8
View File
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
}
}
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
if err != nil {
return fmt.Errorf("migrate shell: %w", err)
if err != nil || len(shellMembers) == 0 {
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
}
for _, member := range shellMembers {
members = append(members, member)
@@ -431,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
// readLegacyPendingUpdate returns the transaction an old file updater left for
// this install and version, or nil when there is none.
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
return nil, nil
}
if err != nil {
return fmt.Errorf("migrate: read legacy pending update: %w", err)
return nil, 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)
return nil, 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)
return nil, 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")
return nil, fmt.Errorf("migrate: pending update target escapes install root")
}
}
return tx, nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
if err != nil || tx == nil {
return err
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
@@ -11,6 +11,10 @@ import (
)
func TestMain(m *testing.M) {
// A legacy-updater child keeps the home its parent test chose.
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
os.Exit(runLegacyChild(mode))
}
testenv.RunWithIsolatedUserState(m)
}
@@ -69,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
writeFlatUnit(t, root, "flat")
writeShellTree(t, root, "flat")
// 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)
@@ -0,0 +1,102 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// errShellMissing identifies a flat release unit whose desktop cannot start:
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
// updaters publish only the three binaries they know by name.
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
const migrateLogName = "legacy-migrate.log"
// refuseShellMissing commits nothing for an absent or incomplete shell: no
// version directory, no current.json, no health mark and no flat cleanup.
// Where an old updater's transaction is still pending it restores the release
// unit that transaction replaced.
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
if cause != nil {
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
}
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
switch {
case rollbackErr != nil:
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
case result.RolledBack:
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
if relaunch {
if err := startRestoredRelease(installRoot); err != nil {
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
}
}
}
logMigrateRefusal(installRoot, result.RolledBack, refusal)
return refusal
}
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
// Windows cannot replace the running migrator image, and its installer
// carries the shell into update mode, so this state is Unix-only.
if runtime.GOOS == "windows" {
return repair.UpdateRollbackResult{}, nil
}
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
if err != nil || tx == nil {
return repair.UpdateRollbackResult{}, err
}
result, err := repair.RollbackPendingUpdateExact(tx)
if err == nil && result.MixedInstall {
err = fmt.Errorf("rollback left a mix of two releases")
}
return result, err
}
// startRestoredRelease relaunches the way the legacy updater itself does:
// through its Guard when the restored unit has one, else the desktop directly.
func startRestoredRelease(installRoot string) error {
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
return startDetached(installRoot, guard, "launch", "--detach")
}
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
return startDetached(installRoot, desktop)
}
return fmt.Errorf("restored release has no entry point")
}
func startDetached(dir, path string, args ...string) error {
cmd := exec.Command(path, args...)
cmd.Dir = dir
return cmd.Start()
}
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
// updater has no terminal, so stderr alone reaches nobody.
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
home := config.ReasonixHomeDir()
if home == "" {
return
}
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
}
@@ -0,0 +1,290 @@
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const (
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
legacyFromVersion = "v1.19.2"
legacyToVersion = "v1.39.4"
childShellMissing = 3
)
func writeShellTree(t *testing.T, root, label string) {
t.Helper()
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
}
func snapshotFlat(t *testing.T, root string) map[string]string {
t.Helper()
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, entry := range entries {
if !entry.Type().IsRegular() {
out[entry.Name()] = "<dir>"
continue
}
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
if err != nil {
t.Fatal(err)
}
out[entry.Name()] = string(data)
}
return out
}
func assertNothingCommitted(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
t.Fatalf("current.json written for a release unit without a shell: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
}
}
func assertRefusalLogged(t *testing.T, want ...string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
if err != nil {
t.Fatalf("refusal was not logged: %v", err)
}
for _, w := range append([]string{"code=shell_missing"}, want...) {
if !strings.Contains(string(data), w) {
t.Fatalf("refusal log %q lacks %q", data, w)
}
}
}
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
cases := map[string]func(t *testing.T, root string){
"absent": func(*testing.T, string) {},
"incomplete": func(t *testing.T, root string) {
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
t.Fatal(err)
}
},
}
for name, shell := range cases {
t.Run(name, func(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
writeFlatUnit(t, root, "new")
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
t.Fatal(err)
}
shell(t, root)
before := snapshotFlat(t, root)
err := migrateWithRelaunch(root, legacyToVersion, false)
if !errors.Is(err, errShellMissing) {
t.Fatalf("migrate = %v, want errShellMissing", err)
}
assertNothingCommitted(t, root)
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
}
assertRefusalLogged(t, "rolled_back=false")
})
}
}
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
// runs the updater's own transaction steps from inside the install, publishing
// only desktop, CLI and the migrator under the Guard name.
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the file-by-file legacy updater is the Unix portable path")
}
t.Setenv("REASONIX_HOME", t.TempDir())
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
oldDesktop, err = os.ReadFile(self)
if err != nil {
t.Fatal(err)
}
root = t.TempDir()
for name, body := range map[string][]byte{
"reasonix-desktop": oldDesktop,
"reasonix-guard": []byte("old-guard"),
"reasonix": []byte("old-cli"),
} {
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
t.Fatal(err)
}
}
if withShell {
writeShellTree(t, root, "new")
}
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
t.Fatalf("legacy updater child exited %d: %s", code, out)
}
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
}
return root, oldDesktop
}
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
t.Helper()
cmd := exec.Command(exe, "-test.run=^$")
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode(), out.String()
}
if err != nil {
t.Fatalf("run %s child: %v", mode, err)
}
return 0, out.String()
}
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
root, oldDesktop := legacyUpdatedInstall(t, false)
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
if code != childShellMissing {
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
}
assertNothingCommitted(t, root)
for name, want := range map[string][]byte{
"reasonix-desktop": oldDesktop,
"reasonix-guard": []byte("old-guard"),
"reasonix": []byte("old-cli"),
} {
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
}
}
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
}
if repair.PendingUpdateExists() {
t.Fatal("legacy transaction still pending after rollback")
}
assertRefusalLogged(t, "rolled_back=true")
}
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
root, _ := legacyUpdatedInstall(t, true)
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
t.Fatalf("migrator child exited %d: %s", code, out)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != legacyToVersion {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
t.Fatalf("active desktop = %q", got)
}
if !installlayout.HasActiveShell(root) {
t.Fatal("active version lost its shell")
}
if repair.PendingUpdateExists() {
t.Fatal("legacy transaction was not committed")
}
}
func runLegacyChild(mode string) int {
root := os.Getenv(legacyChildRootEnv)
var err error
switch mode {
case "legacy-update":
err = applyLegacyUpdate(root)
case "migrate":
err = migrateWithRelaunch(root, legacyToVersion, false)
if errors.Is(err, errShellMissing) {
fmt.Fprintln(os.Stderr, err)
return childShellMissing
}
default:
err = fmt.Errorf("unknown child mode %q", mode)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
// for step, with the running desktop as the updater.
func applyLegacyUpdate(root string) error {
exe, err := os.Executable()
if err != nil {
return err
}
migrator, err := os.ReadFile(exe)
if err != nil {
return err
}
desktop := filepath.Join(root, "reasonix-desktop")
guard := filepath.Join(root, "reasonix-guard")
cli := filepath.Join(root, "reasonix")
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
if err != nil {
return fmt.Errorf("prepare: %w", err)
}
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
if err != nil {
return fmt.Errorf("claim: %w", err)
}
defer release()
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
return err
}
var receipts []repair.FileUpdateInstallReceipt
for _, member := range []struct {
path string
body []byte
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
if err != nil {
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
}
receipts = append(receipts, receipt)
}
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
return fmt.Errorf("record installed: %w", err)
}
return repair.ClearUpdateApplyFailureExact(claimed)
}
+2 -2
View File
@@ -193,8 +193,8 @@ not depend on homepage badge semantics. Self-update behavior by platform:
### Code signing — first launch
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
per release; `release-desktop.yml` verifies every payload binary through
- **Windows** — stable builds carry an Authenticode signature (Certum certificate;
`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*.
+16 -11
View File
@@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() {
if len(legacy) == 0 {
return
}
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
_ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
seen := make(map[string]bool, len(f.Projects)+len(legacy))
for _, p := range f.Projects {
seen[p.Root] = true
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
}
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
if a.isRemoteTab(tabID) {
used, window, ok := a.remoteContextSnapshot(tabID)
if ok {
return ContextInfo{Used: used, Window: window}
}
return ContextInfo{}
}
read := a.captureContextRead(tabID)
ctrl := read.ctrl
var info ContextInfo
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
}
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
if err != nil {
return p.ShouldAutoStart()
}
return enabled
return config.MCPServerEnabled(p, workspace)
}
func mcpRuntimeState(status string) string {
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
if !found {
return fmt.Errorf("no configured MCP server named %q", name)
}
if err := config.RecordExplicitStart(entry, root); err != nil {
return err
}
controllers := a.mcpControllersSharingHost(host, name, ctrl)
for i := range controllers {
if controllers[i].ctrl == ctrl {
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
}
a.bumpExtensionGeneration()
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
if err := config.RecordExplicitStart(updated, root); err != nil {
return err
}
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
recordMCPFailure(ctrl, updated, err)
return nil
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
return err
}
_, err := config.UpsertPluginInSourceForRoot(root, entry)
_, err := config.UpsertPluginKeepingDecision(root, entry)
return err
}
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
return "", ""
}
func (a *App) activeWorkspaceBase() (string, error) {
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
}
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
tabID = strings.TrimSpace(tabID)
a.mu.RLock()
+42 -71
View File
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
approveWorkspace(t, project)
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
}
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
// With no user config yet, Settings start from the defaults, not from the
// checkout's reasonix.toml, and the first edit writes only the user's choice.
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "legacy-provider/legacy-model"
[desktop]
language = "zh"
layout_style = "workbench"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
status_bar_style = "text"
status_bar_items = ["model", "cache", "balance"]
`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
approveWorkspace(t, project)
t.Chdir(project)
app := NewApp()
got := app.Settings()
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
}
if err := app.SetDesktopLanguage("en"); err != nil {
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
}
}
@@ -3227,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
approveWorkspace(t, projectRoot)
app := NewApp()
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
@@ -3954,6 +3928,7 @@ default_effort = "max"
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, projectA)
staleConfig := `default_model = "stale/stale-model"
[[providers]]
name = "stale"
@@ -3966,6 +3941,7 @@ reasoning_protocol = "none"
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, projectB)
topicID := "topic_effort_owner"
topicTitle := "Effort owner"
@@ -6909,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
return nil
}
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
}
return
}
}
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
isolateDesktopUserDirs(t)
reasonixHome := config.ReasonixHomeDir()
@@ -7006,6 +6949,8 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -7093,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -7116,6 +7062,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -7164,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
host := plugin.NewHost()
defer host.Close()
@@ -7202,6 +7150,7 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -7352,6 +7301,7 @@ url = %q
`, projectServer.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
host := plugin.NewHost()
t.Cleanup(host.Close)
@@ -7505,6 +7455,7 @@ network = true
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
entry := config.PluginEntry{
Name: "h", Command: exe, Args: helperArgs,
@@ -8323,6 +8274,7 @@ args = ["serve"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8434,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8961,6 +8915,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8992,6 +8948,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9022,6 +8979,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9056,6 +9015,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9115,6 +9076,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9192,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9230,6 +9193,8 @@ tier = "background"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9280,6 +9245,8 @@ name = "codegraph"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
reg := tool.NewRegistry()
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
@@ -9338,6 +9305,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9431,6 +9400,8 @@ tier = "eager"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+25 -1
View File
@@ -90,7 +90,12 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
}
for _, item := range app.ListTabs() {
if item.ID == tab.ID && item.Authentication != nil && item.Authentication.Ready() {
refreshed <- true
// 非阻塞发送:该回调跑在共享的 asyncRuntimeEmitter.run 上,
// 阻塞发送会把后续所有 runtime 事件永久卡死。
select {
case refreshed <- true:
default:
}
return
}
}
@@ -103,4 +108,23 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
case <-time.After(5 * time.Second):
t.Fatal("retry did not publish a refresh of authoritative authentication state")
}
// 回归:缓冲已满时再次触发同一事件,emitter 泵必须继续排空而不是停在
// tabs.go 的 emit 调用点上(曾实证 chan send 卡死 7 分钟)。
for range 3 {
app.runtimeEvents.Emit(app.ctx, tabMetaRefreshEventChannel)
}
deadline := time.Now().Add(5 * time.Second)
for {
app.runtimeEvents.mu.Lock()
busy := app.runtimeEvents.running
app.runtimeEvents.mu.Unlock()
if !busy {
return
}
if time.Now().After(deadline) {
t.Fatal("asyncRuntimeEmitter pump stayed blocked after repeated refresh events")
}
time.Sleep(10 * time.Millisecond)
}
}
+17 -2
View File
@@ -64,6 +64,10 @@ type botBridgeHub struct {
lastPersistSeq uint64
queue chan desktopBridgeNotification
// closed 终止 run worker;queue 本身不能 close——observe 仍可能在
// controller 事件 goroutine 上并发 enqueue,向已关闭 channel 发送会 panic。
closed chan struct{}
closeOnce sync.Once
}
type desktopPendingPrompt struct {
@@ -135,11 +139,17 @@ func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
takeovers: make(map[string]bot.DesktopWatchRoute),
takeoverTabs: make(map[string]string),
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
closed: make(chan struct{}),
}
go h.run()
return h
}
// Close 停掉通知 worker goroutine。幂等;App 关停时调用。
func (h *botBridgeHub) Close() {
h.closeOnce.Do(func() { close(h.closed) })
}
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
// 只做内存记账和入队,不做任何阻塞调用。
func (h *botBridgeHub) observe(tabID string, e event.Event) {
@@ -209,8 +219,13 @@ func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
}
func (h *botBridgeHub) run() {
for n := range h.queue {
h.deliver(n)
for {
select {
case <-h.closed:
return
case n := <-h.queue:
h.deliver(n)
}
}
}
+6
View File
@@ -29,6 +29,12 @@ func (a *App) newBotBridge() *botBridgeHub {
})
}
func (a *App) stopBotBridge() {
if a.botBridge != nil {
a.botBridge.Close()
}
}
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
+23
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log/slog"
"runtime"
"strings"
"testing"
"time"
@@ -129,6 +130,28 @@ func testGroupRoute() bot.DesktopWatchRoute {
return r
}
// newBotBridgeHub 启动一个 run worker;Close 必须让它退出,否则每个 App 实例
// 都泄漏一个 goroutine(转储曾观测到上千个)。enqueue 在 Close 后仍不得 panic:
// observe 可能在 controller 事件 goroutine 上与关停并发。
func TestBridgeCloseStopsRunGoroutine(t *testing.T) {
before := runtime.NumGoroutine()
env := newBridgeTestEnv(nil)
env.hub.SetWatch(testWatchRoute(), true)
env.hub.Close()
env.hub.Close() // 幂等
// 有订阅者时 observe 会真正 enqueue;Close 后不得 panic。
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("hub run goroutine still alive after Close: %d goroutines > baseline %d", runtime.NumGoroutine(), before)
}
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
"reasonix/internal/session"
)
type BotRuntimeStatusView struct {
@@ -123,7 +124,7 @@ func (a *App) refreshBotRuntime() {
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
bridge = a.botBridge
}
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge, a.historicalSessionService)
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
@@ -151,7 +152,7 @@ func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
return a.botRuntime.snapshot()
}
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge, sessionServiceForRoot func(string) (*session.Service, error)) error {
if r == nil {
return nil
}
@@ -234,6 +235,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
OnToolApprovalModeChange: onToolApprovalModeChange,
Desktop: bridge,
SessionServiceForRoot: sessionServiceForRoot,
}
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
+30 -155
View File
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
}
}
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
// A checkout's [bot] never reaches the bot runtime or the user's config, with
// or without a user config of its own.
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
for _, withUser := range []bool{false, true} {
isolateDesktopUserDirs(t)
if withUser {
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() != "dark" {
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
t.Fatalf("write project config: %v", err)
}
t.Chdir(project)
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
}
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
}
}
}
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
close_behavior = "quit"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
settings := NewApp().Settings()
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
}
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
}
}
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"fmt"
"path/filepath"
"strings"
"reasonix/internal/config"
"reasonix/internal/session"
)
const embeddedBotSessionPrefix = "bot-session:local:"
// Embedded bot conversations keep their existing project-local store. Resolve
// the sidebar's stable session reference there before creating a surface; a
// forged reference must not open a session belonging to another workspace.
func (a *App) embeddedBotSessionPath(scope, workspaceRoot, route string) (string, error) {
id := strings.TrimPrefix(route, embeddedBotSessionPrefix)
if err := session.ValidateSessionID(id); err != nil {
return "", err
}
root := globalTabWorkspaceRoot()
if scope == "project" {
root = normalizeProjectRoot(workspaceRoot)
if root == "" {
return "", fmt.Errorf("workspaceRoot is required")
}
}
storeRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
service, err := a.historicalSessionService(storeRoot)
if err != nil {
return "", err
}
info, err := service.Query().Stat(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: id})
if err != nil {
return "", err
}
same, err := sameDesktopPathStrict(info.CWD, root)
if err != nil {
return "", err
}
if !same {
return "", errSessionWorkspaceConflict
}
path := filepath.Join(storeRoot, id)
if !nativeStoredDirectory(path) {
return "", session.ErrSessionNotFound
}
return path, nil
}
+10 -2
View File
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
// optional Git isolation path. A false result never disables writing itself;
// the cross-platform workspace writer lease remains the no-Git fallback.
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
repo, err := a.workspaceRepoForRoot(workspaceRoot)
if err != nil {
return worktree.Availability{Reason: err.Error()}
}
return inspectDeliveryWorktree(a.bootContext(), repo)
}
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
return worktree.Result{}, err
}
defer releaseAdmission()
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
repo, err := a.workspaceRepoForRoot(workspaceRoot)
if err != nil {
return worktree.Result{}, err
}
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
}()
if err != nil {
return IsolatedWorktreeOpenResult{}, err
+5 -2
View File
@@ -7,13 +7,15 @@ import (
"testing"
"reasonix/internal/config"
"reasonix/internal/gitcmd"
"reasonix/internal/worktree"
)
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
original := inspectDeliveryWorktree
t.Cleanup(func() { inspectDeliveryWorktree = original })
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := filepath.Base(repo.Dir)
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
}
got := NewApp().DeliveryWorktreeAvailability("project")
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
original := createDeliveryWorktree
t.Cleanup(func() { createDeliveryWorktree = original })
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
source := filepath.Base(sourceRepo.Dir)
if source != "source-project" {
t.Fatalf("source = %q", source)
}
+11 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
@@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) {
// updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the
// desktop-projects cross-process file lock.
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error {
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error {
f := loadProjectsFile()
previous := make([]string, 0, len(f.Projects))
for _, project := range f.Projects {
previous = append(previous, project.Root)
}
changed, err := mutator(&f)
if err != nil {
return err
@@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo
if !changed {
return nil
}
if assignCollisions {
if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil {
return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err)
}
}
return saveProjectsFile(f)
}
+6 -3
View File
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
result.FallbackToShared = true
} else {
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
if !avail.Available {
result.FallbackToShared = true
} else if avail.SourceDirty {
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
return worktree.Result{}, err
}
defer releaseAdmission()
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
}()
if createErr != nil {
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
@@ -280,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator
if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
}
a.registerProjectRoot(workspaceRoot)
if err := a.registerProjectRoot(workspaceRoot); err != nil {
return forkedSessionTabOpen{}, err
}
}
if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
(*hook)()
+16 -10
View File
@@ -8,6 +8,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/gitcmd"
"reasonix/internal/worktree"
)
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
rollbackDeliveryWorktree = origRollback
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
}
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
source := sourceRepo.Dir
return worktree.Result{
WorkspaceRoot: isolatedRoot,
WorktreeRoot: filepath.Dir(isolatedRoot),
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
inspectDeliveryWorktree = origInspect
createDeliveryWorktree = origCreate
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
}
createCalls := 0
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
createCalls++
return worktree.Result{}, nil
}
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
inspectDeliveryWorktree = origInspect
createDeliveryWorktree = origCreate
})
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
return worktree.Availability{Reason: "not a repository"}
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
t.Fatal("fallback must not create a worktree")
return worktree.Result{}, nil
}
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
createDeliveryWorktree = origCreate
rollbackDeliveryWorktree = origRollback
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root}
}
created := worktree.Result{
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
Branch: "reasonix/delivery-test",
Head: "deadbeef",
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
return created, nil
}
rollbackCalls := 0
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
rollbackDeliveryWorktree = origRollback
forkTabBeforePublishHookForTest.Store(nil)
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root}
}
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
Branch: "reasonix/delivery-preserved",
Head: "deadbeef",
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
return created, nil
}
rollbackCalls := 0
+5 -5
View File
@@ -32,7 +32,7 @@
"test:code-browser": "node bench/code-theme.mjs",
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
"test:session-export-browser": "node bench/session-export.mjs",
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
@@ -72,14 +72,14 @@
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@modelcontextprotocol/ext-apps": "1.7.5",
"@tanstack/react-virtual": "^3.14.10",
"@tanstack/react-virtual": "^3.14.13",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"anser": "^2.3.5",
"hast-util-to-jsx-runtime": "2.3.6",
"highlight.js": "^11.12.0",
"html-url-attributes": "3.0.1",
"katex": "^0.18.6",
"katex": "^0.18.9",
"lucide-react": "^1.41.0",
"mermaid": "^11.17.2",
"qrcode.react": "^4.2.0",
@@ -103,7 +103,7 @@
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@typescript-eslint/parser": "^8.69.0",
"@typescript-eslint/parser": "^8.70.1",
"@vitejs/plugin-react": "^6.1.1",
"decode-named-character-reference": "^1.3.0",
"eslint": "^10.10.0",
@@ -113,7 +113,7 @@
"mdast-util-math": "^3.0.0",
"playwright": "^1.63.0",
"terser": "^5.51.2",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"vite": "^8.2.2"
},
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { act } from "react";
import { createTranscriptHarness } from "./transcript-dom-harness";
import type { Item } from "../lib/useController";
const page: Item[] = [];
for (let turn = 1; turn <= 4; turn += 1) {
page.push(
{ kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn },
{ kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false },
);
}
const harness = await createTranscriptHarness({ deterministic: true });
try {
let loads = 0;
const window = { hasNewerHistory: true, historyStartTurn: 0, historyEndTurn: 4, totalTurns: 20, geometrySessionKey: "newer-auto" };
const props = { ...window, onLoadNewerHistory: async () => { loads += 1; return "loaded" as const; } };
await harness.render(page, props);
await harness.settle();
assert.equal(loads, 1, "a reader sitting at the bottom of a window with newer history requests the next page once");
await harness.render([...page], props);
await harness.settle();
assert.equal(loads, 1, "a republished window does not request another page");
await harness.render(page, { ...props, hasNewerHistory: false });
await harness.settle();
assert.equal(loads, 1, "nothing is requested once the window reaches the latest turn");
let blocked = 0;
const guarded = { ...window, geometrySessionKey: "newer-auto-selected", onLoadNewerHistory: async () => { blocked += 1; return "loaded" as const; } };
await harness.render(page, { ...guarded, hasNewerHistory: false });
await harness.settle();
const selected = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a2"] .md p')!;
const range = harness.dom.window.document.createRange();
range.selectNodeContents(selected.firstChild!);
const selection = harness.dom.window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
await harness.render(page, guarded);
await harness.settle();
assert.equal(blocked, 0, "a native selection in the transcript blocks the automatic request");
let failed = 0;
selection.removeAllRanges();
const failing = { ...window, geometrySessionKey: "newer-auto-failed", onLoadNewerHistory: async () => { failed += 1; return "empty" as const; } };
await harness.render(page, failing);
await harness.settle();
await harness.render([...page], failing);
await harness.settle();
assert.equal(failed, 1, "a page that did not load is not retried without the reader leaving and returning to the bottom");
const manual = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!;
await act(async () => manual.click());
assert.equal(failed, 2, "the manual button still requests a page");
console.log("chat newer auto-load: bottom request, no repeat, selection guard and manual button passed");
} finally { await harness.unmount(); await harness.close(); }
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { createTranscriptHarness } from "./transcript-dom-harness";
import type { Item } from "../lib/useController";
const harness = await createTranscriptHarness({ deterministic: true });
const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts");
const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx");
const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts");
const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts");
const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts");
const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx");
const source = new ChatSource("reasoning-load-full");
let fail = false;
let release: (() => void) | undefined;
const loads: string[] = [];
const loader = new ChatContentLoader(undefined, (item, field) => {
if (field !== "reasoning") return Promise.resolve("");
loads.push(item.id);
if (fail) return Promise.reject(new Error("unavailable"));
return new Promise<string>(resolve => { release = () => resolve((item as { reasoning: string }).reasoning); });
});
const scroll = new ChatScrollController("reasoning-load-full");
const mounts = new ChatMountedOrder();
await harness.unmount();
const root = createRoot(harness.container);
const question: Item = { kind: "user", id: "q", text: "think" };
const live = (reasoning: string, streaming = true): Item => ({ kind: "assistant", id: "a", text: "", reasoning, streaming });
const publish = async (items: Item[], running: boolean) => {
await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false }));
await harness.settle();
};
const body = () => harness.container.querySelector(".chat-reasoning__body")?.textContent ?? "";
const loadButton = () => [...harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning__body > .btn")][0];
try {
const first = "a".repeat(9000);
await publish([question, live(first)], true);
await act(async () => root.render(createElement(LocaleProvider, null,
createElement(ChatNodeList, { source, mounts, loader, scroll, actions: { openDetails: () => {}, recover: () => {} } }))));
await harness.settle();
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
assert.ok(loadButton(), "long streaming reasoning offers a full-text button");
await act(async () => loadButton()!.click());
await harness.settle();
assert.ok(body().length >= first.length, "clicking load shows the whole reasoning while it streams");
assert.equal(loadButton(), undefined, "the button is gone once the full text is shown");
const grown = first + "b".repeat(500);
await publish([question, live(grown)], true);
assert.ok(body().length >= grown.length, "the loaded view follows the growing stream instead of snapping back");
assert.equal(loadButton(), undefined, "streaming chunks do not bring the button back");
const final = grown + "c".repeat(500);
await publish([question, live(final, false)], false);
assert.ok(body().length >= final.length, "the body never shrinks while the settled load is pending");
assert.equal(loadButton(), undefined, "no button while the settled load is pending");
await act(async () => { release?.(); });
await harness.settle();
assert.ok(body().length >= final.length, "the loaded view survives the settled load");
assert.equal(loadButton(), undefined, "settling does not bring the button back");
assert.equal(loads.length, 1, "the settled load runs once");
const copies = loads.length;
await publish([question, { ...live("d".repeat(9000)), id: "b" }], true);
await act(async () => harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning .copybtn")[1]?.click());
assert.equal(loads.length, copies, "copying while streaming does not start a load");
const start = loads.length;
await publish([question, { ...live("e".repeat(9000)), id: "c" }], true);
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
await act(async () => loadButton()!.click());
const idle = loadButton() ? "" : "gone";
fail = true;
const failedFinal = "e".repeat(9500);
await publish([question, { ...live(failedFinal, false), id: "c" }], false);
await harness.settle();
assert.equal(loads.length, start + 1, "the failing load ran once");
assert.ok(body().length >= failedFinal.length, "a failed load does not shrink the body");
const retry = loadButton();
assert.ok(retry, `a failed load keeps a retry button (${idle})`);
assert.equal(retry!.disabled, false, "the retry button is enabled");
const failedLabel = retry!.textContent;
fail = false;
await act(async () => retry!.click());
await act(async () => { release?.(); });
await harness.settle();
assert.equal(loads.length, start + 2, "retry loads again");
assert.equal(loadButton(), undefined, "a successful retry removes the button");
assert.ok(failedLabel, "the failed state is labelled");
} finally {
await act(async () => root.unmount());
source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose();
await harness.unmount(); await harness.close();
}
console.log("reasoning load full: stays loaded across streaming chunks and settlement");
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { ChatSource, type ChatInput } from "../lib/chatViewSource";
import type { Item } from "../lib/useController";
const tool = (id: string): Item => ({ kind: "tool", id, name: "read_file", args: "", readOnly: true, status: "done", output: "ok" });
const answer = (id: string, streaming = false): Item => ({ kind: "assistant", id, text: "done", reasoning: "", streaming });
const base: ChatInput = { items: [], running: false, hydrating: false, hasOlder: true, loadingOlder: false };
const process = (source: ChatSource, key: string) => {
const node = source.getNodeSnapshot(`${key}:process`);
assert.equal(node?.kind, "process");
return node as Extract<NonNullable<typeof node>, { kind: "process" }>;
};
const head: Item[] = [tool("t1"), tool("t2"), answer("a1")];
const settled = new ChatSource("head-settled");
settled.update({ ...base, items: head });
await Promise.resolve();
assert.ok(process(settled, "history-head").foldable, "a head group with tools can fold");
assert.ok(process(settled, "history-head").collapsed, "a settled head group starts folded");
settled.toggleProcess("history-head");
assert.equal(process(settled, "history-head").collapsed, false, "the head group folds and unfolds by hand");
settled.toggleProcess("history-head");
settled.dispose();
const running = new ChatSource("head-running");
running.update({ ...base, items: [tool("t1"), answer("a1", true)], running: true });
await Promise.resolve();
const live = process(running, "history-head");
assert.ok(!live.foldable && !live.collapsed, "a head group still running stays open");
running.dispose();
const chosen = new ChatSource("head-choice");
chosen.update({ ...base, items: head });
await Promise.resolve();
chosen.toggleProcess("history-head");
const user: Item = { kind: "user", id: "u0", text: "long task", checkpointTurn: 1 };
chosen.update({ ...base, items: [user, ...head], hasOlder: false });
await Promise.resolve();
assert.equal(chosen.getNodeSnapshot("history-head:process"), undefined, "the head key is gone once the user message arrives");
assert.equal(process(chosen, "u0").collapsed, false, "the manual choice follows the turn when its user message loads");
chosen.dispose();
const folded = new ChatSource("head-folded");
folded.update({ ...base, items: head });
await Promise.resolve();
folded.update({ ...base, items: [user, ...head], hasOlder: false });
await Promise.resolve();
assert.ok(process(folded, "u0").collapsed, "an untouched head stays folded after the user message loads");
folded.dispose();
const unrelated = new ChatSource("head-unrelated");
unrelated.update({ ...base, items: head });
await Promise.resolve();
unrelated.toggleProcess("history-head");
const other: Item[] = [{ kind: "user", id: "u9", text: "other", checkpointTurn: 9 }, tool("x1"), answer("ax")];
unrelated.update({ ...base, items: other });
await Promise.resolve();
assert.ok(process(unrelated, "u9").collapsed, "a choice made on the head does not leak onto a turn that does not contain it");
unrelated.dispose();
console.log("chat view: history head fold, running head and choice migration passed");
@@ -48,12 +48,12 @@ const warning: Item = { kind: "notice", id: "warning", level: "warn", text: "int
source.update({ ...input, items: [...settled, warning], running: false });
await Promise.resolve();
const failed = source.getNodeSnapshot("u2:process");
assert.ok(failed?.kind === "process" && !failed.foldable);
assert.ok(failed?.kind === "process" && failed.foldable && !failed.collapsed, "an interrupted turn stays open but can be folded");
assert.ok(source.getOrderSnapshot().includes("warning"));
source.update({ ...input, items: [settled[1]], running: false });
await Promise.resolve();
const incomplete = source.getNodeSnapshot("history-head:process");
assert.ok(incomplete?.kind === "process" && !incomplete.foldable);
assert.ok(incomplete?.kind === "process" && incomplete.foldable, "a group without its user message still has a fold seat");
source.update(input);
source.dispose();
const notified = active;
@@ -120,7 +120,8 @@ const restored = new ChatSource("recovered-weather");
restored.update({ ...input, items: weather, running: false });
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).collapsed, false, "manual open survives session revisit");
restored.update({ ...input, items: [...weather, { kind: "notice", id: "stopped", level: "info", text: "Stopped" }], running: false });
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).foldable, false, "terminal interruption is not hidden by a partial answer");
const interrupted = restored.getNodeSnapshot("weather-user:process") as typeof compact;
assert.ok(interrupted.foldable && !interrupted.collapsed, "terminal interruption stays open and keeps the manual choice");
restored.dispose();
const deliverables = new ChatSource("deliverables");
@@ -199,3 +200,34 @@ const maintenanceProcess = maintenanceSource.getNodeSnapshot("compact-user:proce
assert.ok(maintenanceProcess?.kind === "process" && !maintenanceProcess.members.includes("maintenance:op"), "maintenance card is independent from the previous process fold");
assert.ok(maintenanceSource.getOrderSnapshot().includes("maintenance:op"), "pending maintenance remains mounted");
maintenanceSource.dispose();
const endings: Record<string, Item[]> = {
clean: [{ kind: "assistant", id: "clean-answer", text: "done", reasoning: "", streaming: false, turnFinal: true }],
stopped: [{ kind: "notice", id: "stopped-note", level: "info", text: "Stopped" }],
errored: [{ kind: "notice", id: "errored-note", level: "warn", text: "provider error" }],
unfinishedTool: [{ kind: "tool", id: "hung", name: "fleet", args: "{}", readOnly: true, status: "stopped" }],
streamingLeftover: [{ kind: "assistant", id: "leftover", text: "partial", reasoning: "", streaming: true }],
};
for (const [ending, tail] of Object.entries(endings)) {
const source = new ChatSource(`ending-${ending}`);
const turn: Item[] = [
{ kind: "user", id: "q", text: "go", checkpointTurn: 1 },
{ kind: "tool", id: "t1", name: "bash", args: "{}", readOnly: true, status: "done", output: "ok" },
...tail,
];
source.update({ ...input, items: turn, running: false });
await Promise.resolve();
const seat = () => source.getNodeSnapshot("q:process") as Extract<NonNullable<ReturnType<typeof source.getNodeSnapshot>>, { kind: "process" }>;
assert.ok(seat().foldable, `${ending}: every ended turn has a fold control`);
const startsFolded = seat().collapsed;
assert.equal(startsFolded, ending === "clean", `${ending}: only a settled turn starts folded`);
source.toggleProcess("q");
assert.equal(seat().collapsed, !startsFolded, `${ending}: the control flips the turn`);
source.update({ ...input, items: [...turn], running: false });
await Promise.resolve();
assert.equal(seat().collapsed, !startsFolded, `${ending}: the manual choice survives republication`);
source.toggleProcess("q");
assert.equal(seat().collapsed, startsFolded, `${ending}: the control flips back`);
source.dispose();
}
console.log("chat view: every ended turn is foldable by hand");
@@ -0,0 +1,55 @@
// A busy-window submit (the desktop thinks idle, the serve is still finishing)
// is answered with a durable queue receipt. The composer must show the queue
// entry immediately and treat the message as delivered-to-queue, never as a
// failure — local busy sends behave the same way.
import assert from "node:assert/strict";
import { act } from "react";
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
async function paste(text: string) {
await act(async () => {
const event = new window.Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
await flush();
});
}
async function send() {
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
}
const emptySnapshot = { revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 };
const queuedSnapshot = {
revision: 2, paused: false, recovered: false, itemsCount: 1, bytes: 12, maxItems: 64, maxBytes: 1024,
items: [{ id: "queued-1", intent: "followup", state: "queued", preview: "queued while busy" }],
};
const queuedError = () => Object.assign(new Error("reasonix_error:queued_followup"), {
code: "business",
data: { method: "SubmitRemoteTabWithSubmission", queuedFollowup: { itemId: "queued-1", disposition: "queued_followup", position: 0, paused: false } },
});
installDom();
installBridgeApp({
InboxQueueForTarget: async () => ({ outcome: "applied", snapshot: emptySnapshot }),
InboxSnapshot: async () => queuedSnapshot,
});
await renderComposer({
running: false,
inboxSessionPath: "session-a",
onSend: async () => { throw queuedError(); },
});
await paste("queued while busy");
await send();
// The queue strip reconciles through the snapshot refresh; let it settle.
await act(async () => { await flush(); await flush(); await new Promise(r => setTimeout(r, 60)); await flush(); });
const body = document.body.textContent ?? "";
assert.ok(body.includes("queued while busy"), "the queued message is visible in the pending queue");
assert.equal(body.includes("Send failed"), false, "a queued submit is not reported as a failed send");
assert.equal(body.includes("Refresh the session"), false, "a queued submit never asks the user to refresh");
assert.equal((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, "",
"the composer clears once the message is queued");
console.log("composer queued follow-up outcome: busy submits surface as a visible queue entry");
process.exit(0);
@@ -0,0 +1,135 @@
// A transient inbox-target fence (session switching, reconnecting, route
// adoption) clears by itself. The composer must wait it out and deliver the
// message instead of surfacing "the message was not sent" for a window the
// user cannot act on.
import assert from "node:assert/strict";
import { act } from "react";
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup";
import { formatInboxError } from "../lib/inboxError";
const TRANSIENT_INBOX_TARGET_CODE = "inbox_target_transient";
const TRANSIENT_GUIDANCE_RETRY_DELAYS_MS = [150, 350, 500, 500];
const pendingSession = followupSessionKey("session-a");
const DEAD_END_COPY = "Refresh the session";
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
const settle = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
async function paste(text: string) {
await act(async () => {
const event = new window.Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
await flush();
});
}
async function send() {
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
}
function transientError() {
return Object.assign(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), { code: "business", data: { transient: true } });
}
function state(scenario: number, phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection {
return { epoch: `transient-test-${scenario}`, revision, topics: [], sessions: [{
tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision,
phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3, turnStartedAt: 0,
pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
}] };
}
const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true };
const receipt = { itemId: "item-1", disposition: "queued_followup", position: 0, paused: false };
// The classifier and the copy table must recognize the bridge's transient mark.
{
assert.equal(formatInboxError(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), "en").includes("switching"), true);
}
// 1. A capture-phase fence is waited out: the send completes normally.
{
installDom();
let captures = 0, posts = 0;
installBridgeApp({
CaptureInboxTarget: async () => { captures++; if (captures <= 2) throw transientError(); return target; },
EnqueueInboxFollowupForTarget: async () => { posts++; return receipt; },
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
});
const SCENARIO = 1;
await renderComposer({ inboxSessionPath: "session-a" });
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
await paste("held during switch");
await send();
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[1] + 80); });
assert.equal(captures, 3, "capture retried until the tab settled");
assert.equal(posts, 1, "the settled send enqueued exactly once");
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "no dead-end copy for a self-clearing window");
}
// 2. An enqueue-phase fence holds the request and auto-delivers on retry.
{
installDom();
let posts = 0;
installBridgeApp({
CaptureInboxTarget: async () => target,
EnqueueInboxFollowupForTarget: async () => { posts++; if (posts === 1) throw transientError(); return receipt; },
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
});
const SCENARIO = 2;
await renderComposer({ inboxSessionPath: "session-a" });
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
await paste("retried until queued");
await send();
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + 80); });
assert.equal(posts, 2, "the held request retried the enqueue");
assert.equal(pendingFollowups.get(pendingSession), undefined, "a delivered request clears its pending identity");
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "delivery never surfaced the dead-end copy");
}
// 3. Outlasting the bound keeps the request visible for later reconciliation
// instead of telling the user to refresh.
{
installDom();
let posts = 0;
installBridgeApp({
CaptureInboxTarget: async () => target,
EnqueueInboxFollowupForTarget: async () => { posts++; throw transientError(); },
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
});
const SCENARIO = 3;
await renderComposer({ inboxSessionPath: "session-a" });
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
await paste("switching forever");
await send();
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + 200); });
assert.equal(posts, 1 + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.length, "the retry bound is finite");
assert.ok(pendingFollowups.get(pendingSession), "an undelivered request stays pending for reconciliation");
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "exhaustion still avoids the dead-end copy");
// The pending store is module-global: retire this scenario's leftover so the
// next one starts from a clean session identity.
pendingFollowups.clear(pendingSession, pendingFollowups.get(pendingSession));
}
// 4. Permanent refusals keep the historical behavior (clear + report).
{
installDom();
installBridgeApp({
CaptureInboxTarget: async () => target,
EnqueueInboxFollowupForTarget: async () => { throw new Error("reasonix_error:inbox_item_too_large"); },
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
});
const SCENARIO = 4;
await renderComposer({ inboxSessionPath: "session-a" });
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
await paste("permanently refused");
await send();
await act(async () => { await settle(60); });
assert.equal(pendingFollowups.get(pendingSession), undefined, "permanent refusals drop the pending identity");
assert.equal(document.body.textContent?.includes("too large"), true, "permanent refusals stay visible to the user");
}
console.log("composer transient guidance: retries a self-clearing fence and keeps the message visible");
// Mounted roots leave timers behind; exit once the scenarios above ran.
process.exit(0);
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
assert.equal(view.context.items, remote.transcript.items);
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
assert.equal(view.composer.modelLabel, "remote-model");
assert.equal(view.composer.cwd, "remote-cwd");
assert.equal(view.composer.turnTokens, 7);
@@ -0,0 +1,13 @@
// Run: tsx src/__tests__/decision-surface-remote.test.ts
import assert from "node:assert/strict";
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
console.log("decision-surface-remote: 4 passed");
@@ -23,6 +23,7 @@ const acceptedTopics: number[] = [];
let intent = 0;
let registration: ReturnType<typeof deferred<string>> | undefined;
let preparationReads = 0;
let lastTopicPath = "";
let api!: ReturnType<typeof useDesktopNavigation>;
const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred<TabMeta>(); pending.set(id, request); return request.promise; };
const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
@@ -30,7 +31,7 @@ const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
registeredNavigationIntent: async seq => registration ? registration.promise : String(seq),
openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`),
switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); },
activateTopic: async (_scope, _workspace, id) => activate(id),
activateTopic: async (_scope, _workspace, id, path) => { lastTopicPath = path; return activate(id); },
openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); },
openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); },
openGlobalTab: async id => { calls.push("tab-global"); return activate(id); },
@@ -104,6 +105,14 @@ try {
await finish("blank:im", validIM);
assert.ok(calls.includes("channel:blank:im:channel.jsonl"));
calls.length = 0;
const botConnection = { ...connection, sessionId: "session:local:bot-conversation" };
const botNavigation = api.enqueueNavigation({ kind: "sidebar-im", connection: botConnection });
await finish("", botNavigation);
assert.equal(lastTopicPath, "bot-session:local:bot-conversation");
assert.ok(!calls.some(value => value.startsWith("open:blank:") || value.startsWith("channel:")),
"canonical bot sessions open through the shared runtime surface, not a new blank tab");
calls.length = 0;
const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" });
await paint(); // Normal commits do not change the request epoch.
@@ -16,6 +16,7 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
const translate: Translator = key => dict[key];
for (const [error, key] of [
["dial tcp: connection refused", "error.connection"],
[{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"],
["lookup host: no such host", "error.dns"],
["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"],
["HTTP 429: too many requests", "error.rateLimit"],
@@ -36,6 +37,10 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
] as const) {
assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`);
}
assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), {
summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR",
});
assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence");
if (locale !== "en") {
for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) {
assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw });
@@ -59,10 +64,11 @@ await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]);
let changeLocale: (locale: Locale) => void;
let toast: ReturnType<typeof useToast>["showToast"];
const raw = "unknown library failure <img src=x onerror=alert(1)>";
const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>";
function Probe() {
changeLocale = useI18n().setPref;
toast = useToast().showToast;
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
}
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>));
@@ -76,9 +82,14 @@ assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw);
assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup");
await act(async () => changeLocale("zh-TW"));
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]);
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]);
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics");
await act(async () => changeLocale("en"));
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]);
await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click());
assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`);
assert.equal(document.querySelector("#transport-error img"), null);
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale");
await act(async () => changeLocale("zh-TW"));
@@ -1,11 +1,6 @@
// Run: tsx src/__tests__/goal-lifecycle-actions.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { GoalLifecycleActions } from "../components/GoalLifecycleActions";
import { LocaleProvider } from "../lib/i18n";
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { url: "http://localhost/" });
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -14,10 +9,16 @@ globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
const React = await import("react");
const { act } = React;
const { createRoot } = await import("react-dom/client");
const { GoalLifecycleActions } = await import("../components/GoalLifecycleActions");
const { LocaleProvider } = await import("../lib/i18n");
let edit: { objective: string; maxGoalRounds: number | null } | undefined;
let paused = 0;
const prompts = ["finish the migration safely", "12"];
window.prompt = () => prompts.shift() ?? null;
window.prompt = () => { throw new Error("prompt() is not supported."); };
window.alert = () => { throw new Error("alert() is not supported."); };
await act(async () => {
createRoot(document.getElementById("root")!).render(
@@ -45,7 +46,28 @@ const pauseButton = buttons.find((button) => button.textContent === "Pause goal"
if (!editButton || !pauseButton) throw new Error("active goal lifecycle actions did not render");
if (pauseButton.disabled) throw new Error("running automatic Goal round is not pausable");
const setValue = async (el: HTMLInputElement | HTMLTextAreaElement, value: string) => {
const proto = el instanceof dom.window.HTMLTextAreaElement ? dom.window.HTMLTextAreaElement.prototype : dom.window.HTMLInputElement.prototype;
await act(async () => {
Object.getOwnPropertyDescriptor(proto, "value")!.set!.call(el, value);
el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
});
};
await act(async () => { editButton.click(); });
const objectiveInput = document.querySelector<HTMLTextAreaElement>("[data-goal-edit-objective]");
const roundsInput = document.querySelector<HTMLInputElement>("[data-goal-edit-rounds]");
if (!objectiveInput || !roundsInput) throw new Error("edit goal did not open an in-app form");
if (objectiveInput.value !== "finish the migration") throw new Error("objective not prefilled");
await setValue(roundsInput, "0");
const form = document.querySelector<HTMLFormElement>("form[data-goal-edit-form]")!;
await act(async () => { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); });
if (edit) throw new Error("invalid rounds must not submit");
if (!document.querySelector("[data-goal-edit-error]")) throw new Error("invalid rounds error not shown");
await setValue(objectiveInput, "finish the migration safely");
await setValue(roundsInput, "12");
await act(async () => { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); });
if (document.querySelector("form[data-goal-edit-form]")) throw new Error("form should close after save");
if (edit?.objective !== "finish the migration safely" || edit.maxGoalRounds !== 12) {
throw new Error(`edit action returned ${JSON.stringify(edit)}`);
}
@@ -19,6 +19,7 @@ let navigationEpoch = 0;
let cancelled = 0;
const opened: string[] = [];
const preparedSelectors: unknown[] = [];
let preparedSource: SessionPreparationView | undefined;
const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
const host = installDesktopHostStub({
CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
@@ -33,6 +34,7 @@ const host = installDesktopHostStub({
},
PrepareSession: async (selector: unknown) => {
preparedSelectors.push(selector);
if (preparedSource) return preparedSource;
return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
},
@@ -133,6 +135,29 @@ const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSour
assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action");
const failingSourceTab = { ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source };
let recoveryOpened = 0;
const openFailures: unknown[] = [];
preparedSource = { operationId: "prepare-conflict", sourceKey: "legacy", status: "failed", revision: 9, errorCode: "workspace_conflict",
errorDetail: "session workspace identity is inconsistent; the session files were left unchanged", retryable: true };
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners tab={failingSourceTab}
navigate={async intent => { openFailures.push(intent); throw new Error("open refused: workspace registry busy"); }}
openRecoveryDetails={() => { recoveryOpened++; }} /></LocaleProvider>));
assert.equal(document.body.textContent?.includes("Review recovery details"), false, "recovery details appear only with a failure");
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
const failureAlert = () => document.querySelector('[role="alert"]');
assert.ok(failureAlert()?.textContent?.includes("records a different project folder"), "the banner names the failure class the host reported");
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
assert.ok(failureAlert()?.textContent?.includes("session workspace identity is inconsistent"), "the host's failure detail is reachable from the banner");
await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Review recovery details")!.click());
assert.equal(recoveryOpened, 1, "the promised recovery details open");
assert.deepEqual(openFailures, [], "a failed preparation never navigates");
preparedSource = { operationId: "prepare-ready", sourceKey: "legacy", status: "ready", revision: 10,
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
assert.ok(failureAlert()?.textContent?.includes("Imported, but the conversation could not be opened"), "an open failure is not reported as an import failure");
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
assert.ok(failureAlert()?.textContent?.includes("open refused: workspace registry busy"), "the open failure keeps its cause");
await act(async () => root.unmount());
host.uninstall();
dom.window.close();
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { getTranscriptStore } from "../lib/transcriptStore";
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
import { initialState, reducer, type State } from "../lib/useController";
const dom = new JSDOM("", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
let revision = 1;
const stub = installDesktopHostStub({
SessionHistoryWindowForTab: async () => ({
status: "ready",
snapshotSequence: revision,
generation: `generation-${revision}`,
totalTurns: 30,
hasOlder: true,
olderCursor: "older-cursor",
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
}),
});
try {
const store = getTranscriptStore();
for (const protocol of [1, 2] as const) {
revision = 1;
const tabId = `history-reload-tab-${protocol}`;
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
const initial = await store.loadLatest(tabId, sessionPath);
assert.equal(initial?.revision, 1);
store.evictTab(tabId);
revision = 2;
let state: State = {
...initialState,
transcriptProtocol: protocol,
historyStartTurn: initial!.startTurn,
historyTotalTurns: initial!.totalTurns,
historyHasOlder: true,
historyHasNewer: false,
historyOlderLoading: false,
historyNewerLoading: false,
historyRevision: 1,
historyDigest: "generation-1",
running: false,
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
sessionRevision: 1, sessionDigest: "generation-1" },
};
const actions: HistoryWindowAction[] = [];
const outcome = await loadHistoryWindow({
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
isCurrent: () => true,
currentState: () => state,
dispatch: action => {
actions.push(action);
state = reducer(state, action);
},
});
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
assert.equal(state.historyRevision, 2);
assert.equal(state.historyDigest, "generation-2");
assert.equal(state.historyOlderLoading, false);
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
}
} finally {
stub.uninstall();
dom.window.close();
}
@@ -0,0 +1,56 @@
// Run: tsx src/__tests__/hydrate-failure-attribution.test.ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
import { hydrateFailureDetail } from "../lib/hydrateErrorState";
import { projectSessionAvailability } from "../lib/sessionAvailability";
import { TranscriptFollowClient } from "../lib/transcriptFollowClient";
import { initialState, reducer } from "../lib/useController";
const SUMMARY = "Failed to load conversation history.";
async function localFollowFailure(cause: unknown): Promise<unknown> {
const client = new TranscriptFollowClient((request: FollowRequest) =>
request.close ? Promise.resolve({ protocolVersion: 2, subscription: "", changes: [], resetRequired: false } as TranscriptFollowResponse)
: Promise.reject(cause), { transport: "local" });
try {
await client.start({ install: () => {}, changes: () => {}, connection: () => {} });
} catch (error) {
return error;
}
assert.fail("follow start resolved although the host rejected the baseline read");
}
test("a failed local follow keeps its stage, reason and host cause in the recovery detail", async () => {
const failure = await localFollowFailure(new Error("session store: database is locked"));
const detail = hydrateFailureDetail(SUMMARY, failure);
assert.ok(detail.startsWith(SUMMARY), detail);
assert.match(detail, /baseline_read\.transport_rejected/);
assert.match(detail, /session store: database is locked/);
const failed = reducer({ ...initialState, hydrating: true }, { type: "hydrate_error", reason: "startup", error: detail });
const availability = projectSessionAvailability({ local: failed });
assert.deepEqual({ kind: availability.kind, source: availability.source }, { kind: "error", source: "history" });
assert.equal(availability.detail, detail, "the banner's details show the cause, not only the summary");
});
test("causes without a follow identity keep their message; an absent cause keeps the summary alone", () => {
assert.equal(hydrateFailureDetail(SUMMARY, new Error("resident snapshot unreadable")), `${SUMMARY}\nresident snapshot unreadable`);
assert.equal(hydrateFailureDetail(SUMMARY, "plain rejection"), `${SUMMARY}\nplain rejection`);
assert.equal(hydrateFailureDetail(SUMMARY, undefined), SUMMARY);
assert.equal(hydrateFailureDetail(SUMMARY, new Error(" ")), SUMMARY);
});
test("every local history-read failure hands its cause to the recovery detail", () => {
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
assert.doesNotMatch(controller, /type: "hydrate_error", reason, error: t\("history\.failedLoadHistory"\) \}/,
"a history-read failure must not replace its cause with the generic summary");
assert.doesNotMatch(controller, /const error = t\("history\.failedLoadHistory"\);/,
"the follow failure must not be replaced by the generic summary");
assert.equal(controller.match(/hydrateFailureDetail\(t\("history\.failedLoadHistory"\), /g)?.length, 2,
"both the follow path and the readable-baseline path attribute their failure");
});
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
ok(
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
"App decision surface recomputes when an MCP interaction arrives",
);
@@ -0,0 +1,131 @@
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
import { ToastProvider } from "../lib/toast";
import { LocaleProvider } from "../lib/i18n";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
if (value) passed += 1; else failed += 1;
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
return dom;
}
function topic(id: string): ProjectNode {
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
}
const inside = topic("inside");
const sibling = topic("sibling");
const outside = topic("outside");
const loose = topic("loose");
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, sortMode: "created", bindings });
const loaded = organization.groupsFor(folder).length > 0;
return <>
<output id="loaded">{loaded ? "yes" : "no"}</output>
{folder.children!.map((node) => {
const row = organization.topicRow(node, false);
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
})}
</>;
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function fireDrag(type: string, target: Element, data: Map<string, string>) {
const event = new window.Event(type, { bubbles: true, cancelable: true });
const dataTransfer = {
setData: (format: string, value: string) => { data.set(format, value); },
getData: (format: string) => data.get(format) ?? "",
effectAllowed: "all",
dropEffect: "none",
};
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
Object.defineProperty(event, "clientY", { value: 10 });
target.dispatchEvent(event);
}
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
const dom = installDom();
const mutations: SessionOrganizationMutation[] = [];
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
const bindings: ProjectTreeOrganizationBindings = {
ReorderTopics: async () => {},
ListProjectGroups: async () => [],
SaveSessionGroups: async () => {},
GetSessionOrganization: async () => snapshot(),
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
};
const root = createRoot(document.getElementById("root")!);
await act(async () => {
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
await flush();
});
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
const data = new Map<string, string>();
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
await act(async () => root.unmount());
dom.window.close();
return mutations;
}
console.log("\nproject tree drop onto a group member row");
{
const mutations = await dropOnto("outside", "inside");
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
}
{
const mutations = await dropOnto("sibling", "inside");
ok(mutations.length === 1 && mutations[0].kind === "move",
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
}
{
const mutations = await dropOnto("outside", "loose");
ok(mutations.length === 1 && mutations[0].kind === "move",
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
}
{
const mutations = await dropOnto("outside", "loose");
ok(mutations.length === 1 && mutations[0].sortMode === "created",
`a move carries the sort order on screen, so a first manual move starts from it (got ${JSON.stringify(mutations.map((m) => m.sortMode ?? ""))})`);
}
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,52 @@
// Run: pnpm exec tsx src/__tests__/project-tree-organization-reset.test.tsx
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
import type { ProjectNode, ProjectTreeOrganizationBindings } from "../lib/types";
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
import { ToastProvider } from "../lib/toast";
import { LocaleProvider, t } from "../lib/i18n";
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: "http://localhost/" });
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Node: dom.window.Node, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [] };
const initialOrder = ["ref\0local\0older", "ref\0local\0newer"];
let snapshot: SessionOrganizationSnapshot = { revision: 1, applied: true, manualOrderEnabled: true, order: initialOrder, groups: [] };
const mutations: SessionOrganizationMutation[] = [];
let refreshes = 0;
const bindings: ProjectTreeOrganizationBindings = {
GetSessionOrganization: async () => snapshot,
UpdateSessionOrganization: async (_workspace, revision, mutation) => {
assert.equal(revision, snapshot.revision);
mutations.push(mutation);
snapshot = { ...snapshot, revision: revision + 1, manualOrderEnabled: false };
return snapshot;
},
};
function Harness() {
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => { refreshes += 1; }, bindings });
const resetItem = organization.resetOrderMenuItems(folder, t, () => {});
return <><output id="order">{organization.orderFor?.(folder).join(",")}</output>
{resetItem.map(item => <button key={item.key} id="reset" onClick={item.onSelect}>Reset</button>)}</>;
}
const root = createRoot(document.getElementById("root")!);
const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); };
try {
await act(async () => root.render(<LocaleProvider><ToastProvider><Harness /></ToastProvider></LocaleProvider>));
await flush();
assert.equal(document.getElementById("order")?.textContent, initialOrder.join(","));
await act(async () => { document.getElementById("reset")!.click(); });
await flush();
assert.deepEqual(mutations.map(mutation => mutation.kind), ["reset-order"]);
assert.equal(document.getElementById("order")?.textContent, "");
assert.equal(refreshes, 1);
} finally {
await act(async () => root.unmount());
dom.window.close();
}
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
let reject!: (reason: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
@@ -78,9 +79,21 @@ try {
try {
await render(0);
assert.equal(pending.length, 1, "mount starts one shell read");
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
"an unanswered shell read must say it is loading");
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"pending shell read must not claim there are no projects");
if (scenario.name === "addition") {
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
"fast shell reads must not flash a skeleton");
await act(async () => mock.timers.tick(250));
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
"a delayed shell read shows a skeleton");
}
await act(async () => pending[0].resolve(snapshot(scenario.before)));
await flush();
assert.deepEqual(labels(), scenario.before.map(item => item.label));
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
// A read started before the mutation retains the old shell contents.
// Its scalar revision may equal the post-mutation read: this fixture
@@ -114,6 +127,44 @@ try {
await act(async () => root.unmount());
}
}
pending.length = 0;
localStorage.clear();
const emptyRoot = createRoot(container);
try {
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
await act(async () => pending[0].resolve(snapshot([])));
await flush();
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"an empty answer restores the true empty state");
} finally {
await act(async () => emptyRoot.unmount());
}
pending.length = 0;
localStorage.clear();
const failedRoot = createRoot(container);
try {
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
await flush();
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
"a failed first shell read must settle the loading state");
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
"the empty-state project action must remain available after a failed read");
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
"the empty-state remote action must remain available after a failed read");
} finally {
await act(async () => failedRoot.unmount());
}
} finally {
mock.timers.reset();
dom.window.close();
@@ -2,6 +2,11 @@
import assert from "node:assert/strict";
import { initialState, reducer } from "../lib/useController";
import { historyMessagesToItems } from "../lib/historyItems";
import { historyNoticeItems } from "../lib/controllerNotices";
import { presentError } from "../lib/errorPresentation";
import { en } from "../locales/en";
import type { HistoryMessage } from "../lib/types";
const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
const failed = reducer(started, {
@@ -25,3 +30,17 @@ assert.equal(
"Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
"stable provider id and sanitized path stay in live diagnostic details",
);
const diagnostic = { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" };
const history: HistoryMessage = { role: "notice", content: "opaque provider failure", level: "warn", diagnostic };
const live = reducer(started, { type: "event", e: { kind: "turn_done", err: history.content, diagnostic } });
const interrupted = reducer(started, { type: "event", e: { kind: "turn_done", status: "interrupted", err: history.content, diagnostic } });
const liveNotice = reducer(started, { type: "event", e: { kind: "notice", level: "warn", text: history.content, diagnostic } });
for (const items of [live.items, interrupted.items, liveNotice.items, historyMessagesToItems([history], "full").items, historyNoticeItems(history, "windowed")]) {
const item = items.find(item => item.kind === "notice" && item.level === "warn");
assert.ok(item?.kind === "notice");
assert.deepEqual(item.diagnostic, diagnostic, "live and both history projections retain structured evidence");
const presentation = presentError(item.text, key => en[key], "en", item.diagnostic);
assert.equal(presentation.summary, en["error.transportProtocol"]);
assert.match(presentation.detail, /PROTOCOL_ERROR/);
}
@@ -0,0 +1,115 @@
// Switching back to a remote session whose transcript is already resident must
// paint it immediately and reconcile in the background — the local session
// switch experience — instead of paying the network round trips behind a
// loading placeholder every time.
import React, { act } from "react";
import { JSDOM } from "jsdom";
import type { AppBindings } from "../lib/bridge";
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
import type { RemoteSessionApi } from "../lib/useRemoteSession";
import { installDesktopHostStub } from "./desktopHostStub";
let passed = 0, failed = 0;
function ok(value: boolean, label: string) {
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
if (value) passed += 1; else failed += 1;
}
console.log("\nRemote session resident-cache paint");
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.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
type FollowMode = "ok" | "deferred";
const followMode = new Map<string, FollowMode>();
const deferred = new Map<string, (response: TranscriptFollowResponse) => void>();
const answerFor = new Map<string, string>();
const statusFor = () => ({ running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: false });
const inline = (id: string, content: string) => ({ messageId: id, position: 0, version: 1, role: "assistant", eventSequence: 1, visibleTurn: 1, preview: "", inline: { id, role: "assistant", content } });
const followResponse = (tabId: string): TranscriptFollowResponse => ({
protocolVersion: 2, subscription: `sub-${tabId}`, changes: [], resetRequired: false,
snapshot: {
protocolVersion: 1, snapshotId: `cut-${tabId}`, identity: { sessionId: tabId, runtimeEpoch: "epoch", rewriteEpoch: 0, headId: "" },
projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4, records: [], activeRecords: [], activeAttempts: [],
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false,
},
history: { status: "ready", snapshotSequence: 4, coverageSequence: 4, generation: "gen", totalTurns: 1, hasOlder: false, hasNewer: false, messages: [inline(`answer-${tabId}`, answerFor.get(tabId) ?? "follower answer")] },
});
installDesktopHostStub({
async RegisterNavigationIntent() {},
async SetActiveTab() {},
async ForkTargetsRemoteTab() { return { targets: [], verifiable: false }; },
async RemoteTabStatus() { return statusFor(); },
async RemoteTabMetadata() { return { status: statusFor() }; },
async RemoteTabSnapshot() { return { history: [], status: statusFor() }; },
async RemoteTranscriptFollowForTab(tabId: string, request: FollowRequest) {
if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false };
if (request.subscription) return new Promise<TranscriptFollowResponse>(() => {});
if ((followMode.get(tabId) ?? "ok") === "deferred") {
return new Promise<TranscriptFollowResponse>((resolve) => { deferred.set(tabId, resolve); });
}
return followResponse(tabId);
},
async RemoteSessionHistoryWindowForTab() { return new Promise(() => {}); },
} as Partial<AppBindings> as AppBindings);
const [{ createRoot }, { useRemoteSession }, { getTranscriptStore }, { setTranscriptBindingIdentity }] = await Promise.all([
import("react-dom/client"), import("../lib/useRemoteSession"), import("../lib/transcriptStore"), import("../lib/canonicalTranscriptBackend"),
]);
setTranscriptBindingIdentity(() => "remote");
async function flush(ticks = 8) {
for (let i = 0; i < ticks; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 30));
}
let probe: RemoteSessionApi | undefined;
function Probe({ tabId, sessionPath }: { tabId: string; sessionPath: string }) {
probe = useRemoteSession(tabId, "ready", sessionPath);
return null;
}
const root = createRoot(document.getElementById("root")!);
const mount = (tabId: string, sessionPath: string) => act(async () => {
root.render(<Probe tabId={tabId} sessionPath={sessionPath} />);
await flush();
// The follower runtime is dynamically imported on first use.
for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 40));
await flush();
});
const texts = () => (probe?.transcript.items ?? []).flatMap((item) => item.kind === "assistant" ? [item.text] : []);
// 1. Visit session A so its transcript becomes resident, then switch away.
followMode.set("tab-switch", "ok");
answerFor.set("tab-switch", "session A answer");
await mount("tab-switch", "session-a");
// The mount effect's hydrate depends on the follower runtime's dynamic import,
// which settles unreliably in JSDOM (the repo's prime suite is red on HEAD for
// the same reason). Drive it explicitly to reach the state under test.
await act(async () => { await probe?.retryHydration(); await flush(); });
ok(probe?.hydrated === true && texts().includes("session A answer"), "session A hydrates and becomes resident");
// 2. Switching to a session with no resident cut keeps the network path.
followMode.set("tab-switch", "deferred");
await mount("tab-switch", "session-b");
ok(probe?.hydrated === false && probe?.revalidating === false, "an uncached session still waits for its hydrate");
// 3. Switching back paints the resident cut with no network wait.
await mount("tab-switch", "session-a");
ok(texts().includes("session A answer"), "the resident session is visible immediately on switch-back");
ok(probe?.hydrated === true && probe?.revalidating === true, "the painted surface reports revalidating while the hydrate runs");
ok(getTranscriptStore().peek("tab-switch", "session-a") !== undefined, "the paint reads the session-keyed resident cut");
// 4. The background hydrate supersedes the paint.
answerFor.set("tab-switch", "session A revalidated");
await act(async () => { void probe?.retryHydration(); await flush(); });
await act(async () => { deferred.get("tab-switch")?.(followResponse("tab-switch")); await flush(); });
ok(texts().includes("session A revalidated") && !texts().includes("session A answer"), "the authoritative cut replaces the painted one");
ok(probe?.revalidating === false && probe?.hydrated === true, "revalidating clears once the hydrate settles");
await act(async () => { root.unmount(); });
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
@@ -391,6 +391,22 @@ await act(async () => {
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
}
// A CLI serve never binds desktop routing metadata, so its prompts carry no
// runtimeEpoch; the serve fences on the epoch only when it has one.
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
await flush();
});
{
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
await flush();
});
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
await flush();
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
import { t } from "../lib/i18n";
const stored = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem(key: string) { return stored.get(key) ?? null; },
setItem(key: string, value: string) { stored.set(key, value); },
removeItem(key: string) { stored.delete(key); },
clear() { stored.clear(); },
},
});
let contexts = 0;
class FakeAudioContext {
destination = {};
constructor() { contexts++; }
createOscillator() { return { type: "sine", frequency: { setValueAtTime() {} }, connect() {}, start() {}, stop() {} }; }
createGain() {
return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} };
}
close() {}
}
Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
const owner = createRuntimeNotifications(() => ({ activeTabId: "A", t, showToast() {} }));
function chimesFor(event: { err?: string; outcome?: string }, only: "success" | "attention"): number {
setSuccessPreference(only === "success" ? "synth" : "off");
setAttentionPreference(only === "attention" ? "synth" : "off");
const before = contexts;
owner.accept({ event: { kind: "turn_done", tabId: "A", ...event } });
return contexts - before;
}
const readiness = { err: "delivery checks are incomplete", outcome: "final_readiness" };
assert.equal(chimesFor(readiness, "attention"), 1, "a turn that stopped for delivery checks must alert the user");
assert.equal(chimesFor(readiness, "success"), 0, "a turn that stopped for delivery checks is not a success");
for (const outcome of ["recovery_paused", "incomplete_read", "completion_uncertain"]) {
assert.equal(chimesFor({ err: "paused", outcome }, "attention"), 1, `${outcome} waits on the user`);
}
assert.equal(chimesFor({}, "success"), 1, "a clean finish keeps the success chime");
assert.equal(chimesFor({}, "attention"), 0, "a clean finish does not ring the attention chime");
assert.equal(chimesFor({ err: "provider failed" }, "success") + chimesFor({ err: "provider failed" }, "attention"), 0,
"an ordinary failure stays silent");
owner.dispose();
console.log("runtime notifications: paused turn outcomes ring the attention chime");
@@ -35,7 +35,9 @@ try {
assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className),
["topicbar__brand", "btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"],
"the Windows bar leads with the app mark");
assert.equal(document.querySelector(".topicbar__brand-logo")!.tagName, "IMG", "the leading mark is the app logo asset");
const logo = document.querySelector(".topicbar__brand-logo")!;
assert.equal(logo.getAttribute("role"), "img", "the leading mark is the app logo");
assert.equal(logo.getAttribute("aria-label"), "Reasonix");
await paint();
const action = document.querySelector(".topicbar__actions button");
assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1);
@@ -87,7 +87,7 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance,
},
context: {
tabId: remote ? undefined : activeTabId,
tabId: activeTabId,
items: runtime.items, context: runtime.context, usage: runtime.usage,
sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost,
sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens,
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
workspaceConflict: WorkspaceConflictView | null;
pendingClose: PendingClose;
clearContextPending: boolean;
remote?: boolean;
}): AppDecisionSurfaceKind | null {
// A remote session's prompts and forms are answered by RemoteSessionSurface,
// the one owner of the serve's exact prompt route; a footer copy would be a
// second card whose answer has no local session to reach.
if (!input.remote) {
const prompt = projectPromptSurface(input);
if (prompt) return prompt;
}
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
function projectPromptSurface(input: {
approval: State["approval"];
ask: State["ask"];
mcpInteraction: State["mcpInteraction"];
extensionForm: State["extensionForm"];
}): AppDecisionSurfaceKind | null {
if (input.approval) {
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
if (input.ask) return "ask";
if (input.mcpInteraction) return "mcp_interaction";
if (input.extensionForm) return "extension_form";
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
@@ -39,6 +39,7 @@ export type HistoricalPreparationSurface = {
operationId: string;
status: SessionPreparationView["status"];
errorCode?: string;
errorDetail?: string;
retryable: boolean;
revision?: number;
isCurrent?: () => boolean;
@@ -59,7 +60,7 @@ export function reconcileHistoricalPreparation(expected: HistoricalPreparationSu
if (!current || current.operationId !== expected.operationId || current.session !== expected.session
|| expected.isCurrent?.() === false || view.operationId !== expected.operationId
|| view.revision < (current.revision ?? 0) || view.status === "ready") return;
setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode,
setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode, errorDetail: view.errorDetail,
retryable: view.retryable, revision: view.revision });
}
export type NavigationNotice = {
@@ -148,6 +149,8 @@ export async function executeDesktopNavigation(input: DesktopNavigationCapture,
checkpoint();
if (connection.sessionSource === "auto") await ports.openChannelSession(target.value, tab.id, seq);
else await ports.resumeSession(target.value, tab.id, seq);
} else if (target.kind === "bot-session") {
tab = await openTopic(connection.scope, connection.workspaceRoot, "", `bot-session:local:${target.value}`);
} else tab = await openTopic(connection.scope, connection.workspaceRoot, target.value);
checkpoint(); ports.seedTab(tab);
await refresh(); checkpoint(); ports.reveal(); ports.projectChanged(); return;
@@ -216,7 +216,7 @@ export function sidebarImConnectionsFromBot(
return qqConnection ? [qqConnection, ...connectionItems] : connectionItems;
}
function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null {
function mappedSessionTarget(sessionId: string): { kind: "path" | "topic" | "bot-session"; value: string } | null {
const trimmed = sessionId.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
@@ -224,6 +224,10 @@ function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value
const value = trimmed.slice(5).trim();
return value ? { kind: "path", value } : null;
}
if (lower.startsWith("session:local:")) {
const value = trimmed.slice("session:local:".length).trim();
return value ? { kind: "bot-session", value } : null;
}
if (lower.startsWith("topic:")) {
const value = trimmed.slice(6).trim();
return value ? { kind: "topic", value } : null;
@@ -240,7 +244,7 @@ export function taskSessionIDFromPath(path: string): string {
return extension > 0 ? base.slice(0, extension) : base;
}
export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null {
export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic" | "bot-session"; value: string } | null {
return mappedSessionTarget(connection.sessionId);
}
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
workspaceConflict, pendingClose, clearContextPending,
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
const visibleDecisionSurface = decisionSurface;
// Navigation used to hide the entire composer until the controller/runtime
// activation ticket and the transcript paint ticket both settled. That made
@@ -31,7 +31,7 @@ export function useRuntimeNotifications(activeTabId: string | undefined) {
pending.current = [];
};
}, [readPorts]);
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string; outcome?: string }) => {
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
});
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
@@ -310,6 +310,7 @@ export function AppRuntimeView(props: AppRuntimeViewProps) {
})} startupError={navigationCommands.manualCreation && navigationCommands.manualCreation.operation?.phase !== "ready" ? undefined : state.meta?.startupErr}
historical={core.remoteSurfaceActive ? undefined : { tab: activeTab, navigate: session.desktopNavigation.enqueueNavigation,
captureNavigation: () => { const intent = runtime.navigation.currentNavigationIntent(); return () => runtime.navigation.isNavigationIntentCurrent(intent); },
openRecoveryDetails: () => chromeCommands.openSidebarSettings("storage"),
}} />
<ChatPaneRegion
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
</button>
</div>
<section className="sidebar__section sidebar__section--projects">
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
<ProjectTree {...props.projectTree} />
</Suspense>
</section>
<nav className="sidebar__nav sidebar__nav--footer">
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
@@ -1,6 +1,5 @@
import type { ReactNode } from "react";
import { PanelLeft } from "lucide-react";
import logoSymbol from "../assets/logo-symbol.svg";
import { Tooltip } from "../components/Tooltip";
import { WorktreeBadge } from "../components/WorktreeBadge";
@@ -36,7 +35,7 @@ export function TopicbarRegion({ view, commands, children }: {
const { sidebar, title, subtitle } = view;
return <header className="topicbar">
{view.brand && <div className="topicbar__brand">
<img src={logoSymbol} alt="Reasonix" className="topicbar__brand-logo" draggable={false} />
<span role="img" aria-label="Reasonix" className="topicbar__brand-logo" />
</div>}
{view.automationReturn && <button className="btn btn--small" type="button" onClick={event => {
event.currentTarget.focus({ preventScroll: true });
+13 -7
View File
@@ -144,7 +144,7 @@ function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind:
// Empty delivery accounting is not a chat result. Keep meaningful records in details.
if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null;
if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}>
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} />
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} diagnostic={item.diagnostic} />
{summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>}
{item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>}
{item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>}
@@ -232,30 +232,36 @@ function ChatAnswer({ node, loader, source, tabId, hostId }: { node: Extract<Cha
function ChatReasoning({ node, loader, source, scroll }: { node: Extract<ChatNode, { kind: "reasoning" }>; loader: ChatContentLoader; source: ChatSource; scroll: ChatScrollController }) {
const t = useT();
const [result, setResult] = useState<{ item: unknown; text: string }>();
const [following, setFollowing] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const epoch = useRef(0);
useEffect(() => { return () => { epoch.current++; }; }, []);
const text = node.item.reasoning;
const full = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
if (!text) return null;
const needsFull = full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
const streaming = Boolean(node.item.streaming);
const settled = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
const full = settled ?? (following ? text : undefined);
const load = async () => {
const ticket = ++epoch.current; setBusy(true); setError(false);
try {
const value = await loader.load(node.item, "reasoning");
const current = source.getNodeSnapshot(node.key);
if (ticket !== epoch.current || current?.kind !== "reasoning" || (current.item !== node.item && current.item.reasoning !== value)) throw new Error("Reasoning content changed; retry");
setResult({ item: current.item, text: value }); return value;
setResult({ item: current.item, text: value }); setFollowing(false); return value;
}
catch (error) { if (ticket === epoch.current) setError(true); throw error; }
finally { if (ticket === epoch.current) setBusy(false); }
};
useEffect(() => {
if (following && !streaming) void load().catch(() => {});
}, [following, streaming]);
if (!text) return null;
const needsFull = error || full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
return <div className="chat-reasoning"><ReasoningRow text={text} running={node.item.streaming} t={t} beforeToggle={scroll.beforeChange}
duration={node.item.reasoningDurationMs != null ? `${(node.item.reasoningDurationMs / 1000).toFixed(1)}s` : undefined}>
<div className="chat-reasoning__body">{full ?? text.slice(0, 8000)}
{needsFull && <button className="btn" disabled={busy} onClick={() => void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
<CopyButton getText={() => full === undefined ? load() : full} label={t("chat.copyFull")} />
{needsFull && <button className="btn" disabled={busy} onClick={() => streaming ? setFollowing(true) : void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
<CopyButton getText={() => full ?? (streaming ? text : load())} label={t("chat.copyFull")} />
</div></ReasoningRow></div>;
}
+55 -53
View File
@@ -1,9 +1,10 @@
import { emptyComposerDraft, persistentComposerDraft, persistentSnapshot } from "./composerDraftState";
import { clipboardFiles, clipboardHasImageHint, isPasteShortcut, dataURLHash } from "../lib/composerClipboard";
import { composerDraftFingerprint, emptyComposerDraft, persistentComposerDraft, persistentSnapshot } from "./composerDraftState";
import { SessionInputRecovery } from "./SessionInputRecovery";
import { recoveryStatusText, type RecoveryRetry } from "../lib/recoveryStatus";
import { useRuntimeSession } from "../lib/useRuntimeState";
import { isCompactCommand } from "../lib/sessionMaintenanceOperation";
import { pendingFollowups, confirmFollowup, followupNotSubmitted, followupSessionKey, type PendingFollowup } from "../lib/pendingFollowup";
import { pendingFollowups, confirmFollowup, followupNotSubmitted, followupSessionKey, queuedFollowupOutcome, type PendingFollowup } from "../lib/pendingFollowup";
import { useAppNavigationStore } from "../store/appNavigation";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
@@ -23,6 +24,7 @@ import type { ComposerTarget } from "../generated/desktopContract.generated";
import { desktopHost } from "../lib/desktopHost";
import { steerInboxItemForActiveTurn } from "../lib/inboxSubmit";
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
import { captureStableInboxTarget, createTransientGuidance } from "../lib/transientComposerGuidance";
import { inboxScopeKey } from "../lib/composerInboxQueue";
import { useComposerInboxRefresh } from "../lib/useComposerInboxRefresh";
import { useComposerImeGuard } from "../lib/useComposerImeGuard";
@@ -288,46 +290,6 @@ function draftHasAttachmentDedupKey(draft: ComposerDraft, key: AttachmentDedupKe
return Object.values(draft.attachmentDedupKeys).some((existing) => existing.hash === key.hash && existing.source === key.source);
}
function fileKey(file: File): string {
return `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
}
function clipboardFiles(data: DataTransfer): File[] {
const files = Array.from(data.files);
const seen = new Set(files.map(fileKey));
for (const item of Array.from(data.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (!file) continue;
const key = fileKey(file);
if (seen.has(key)) continue;
seen.add(key);
files.push(file);
}
return files;
}
function clipboardHasImageHint(data: DataTransfer): boolean {
const imageType = (value: string) => {
const type = value.toLowerCase();
return type.startsWith("image/") || type.includes("png") || type.includes("jpeg") || type.includes("jpg") || type.includes("tiff");
};
return Array.from(data.items).some((item) => imageType(item.type)) || Array.from(data.types).some(imageType);
}
function isPasteShortcut(e: KeyboardEvent<HTMLElement>): boolean {
return e.key.toLowerCase() === "v" && (e.metaKey || e.ctrlKey) && !e.altKey;
}
async function dataURLHash(dataUrl: string): Promise<string> {
try {
const res = await fetch(dataUrl);
return sha256(await res.blob());
} catch {
return "";
}
}
function composerMaxHeight(): number {
if (typeof window === "undefined") return COMPOSER_MAX_HEIGHT;
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
@@ -833,6 +795,9 @@ export function Composer({
);
const guidanceReceiptTrackerRef = useRef<GuidanceReceiptTracker | null>(null);
guidanceReceiptTrackerRef.current ??= createGuidanceReceiptTracker();
// Messages held behind a transient target fence, keyed by follow-up key so a
// dismissal or a second send can cancel the pending retry loop.
const transientRetriesRef = useRef(new Map<string, { cancelled: boolean }>());
const selfDispatchedGuidanceByDraftRef = useRef<Record<string, string[]>>({});
const submittingRef = useRef<false | "message" | "compact">(false);
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
@@ -2073,15 +2038,9 @@ export function Composer({
showToast(text, "warn");
}, [showToast, t]);
const followupDraftFingerprint = (key: string): string => {
const draft = key === activeDraftKeyRef.current ? {
text: textRef.current, invocations: invocationsRef.current, attachments: attachmentsRef.current,
workspaceRefs: workspaceRefsRef.current, sessionRefs: sessionRefsRef.current,
selectedTextRefs: selectedTextRefsRef.current, pastedBlocks: pastedBlocksRef.current,
} : draftsBySessionRef.current[key] ?? emptyComposerDraft();
return JSON.stringify([draft.text, draft.invocations, draft.attachments, draft.workspaceRefs,
draft.sessionRefs, draft.selectedTextRefs, draft.pastedBlocks]);
};
const followupDraftFingerprint = (key: string): string => composerDraftFingerprint(
key === activeDraftKeyRef.current ? snapshotComposerDraft() : draftsBySessionRef.current[key] ?? emptyComposerDraft(),
);
const submit = (guideCurrent = false) => trackPersistentTask(activeDraftKeyRef.current, performSubmit(guideCurrent));
const performSubmit = async (guideCurrent = false, modelChoice?:ModelApplicationChoice) => {
@@ -2153,6 +2112,16 @@ export function Composer({
let submissionAttachmentTarget: string | undefined;
let attachmentSubmissionId: string | undefined;
let attachmentSubmit: Awaited<ReturnType<typeof loadAttachmentSubmit>> | undefined;
// Captured for the outer catch: a transient submit failure still holds the
// exact message (display and submit text) the user typed.
let submittedDisplayText = "";
let submittedSubmitText = "";
let submittedStructured: StructuredInvocationSubmit | undefined;
const holdTransientGuidance = createTransientGuidance({
app, transientRetriesRef, pendingKeyRef, submitPendingKey, submitDraftKey, submitTabId,
inboxSessionPath, submittedDraft, queueOnly, followupDraftFingerprint, clearSubmittedDraft,
setGuidanceRetryNonce, showToast, t, locale,
});
try {
submissionCapture = onCaptureSubmit?.(persistentSnapshot(snapshotComposerDraft()));
if (onCaptureSubmit && !submissionCapture) return;
@@ -2162,8 +2131,7 @@ export function Composer({
await restoreExternalFolderReferences(app, bridgeTarget, currentWorkspaceRefs);
}
if (queueOnly && !submitPendingKey) throw new Error("reasonix_error:inbox_not_submitted");
const target = running && app.CaptureInboxTarget
? await app.CaptureInboxTarget(submitTabId || "", inboxSessionPath || "") : undefined;
const target = running ? await captureStableInboxTarget(app, submitTabId || "", inboxSessionPath || "") : undefined;
const orderedAttachments = sortComposerAttachments(currentAttachments);
const refs = [
...currentWorkspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
@@ -2175,6 +2143,7 @@ export function Composer({
...currentSelectedTextRefs.map(formatSelectionLabel),
].join(" ");
const displayText = [trimmedText, displayRefs].filter(Boolean).join(trimmedText && displayRefs ? " " : "");
submittedDisplayText = displayText;
// PR-B: when past:chats refs are attached, prepend their formatted transcript
// to submitText only (displayText stays unchanged so the user still sees their
// original prompt in the input preview). With no refs we keep the original
@@ -2185,6 +2154,7 @@ export function Composer({
const baseSubmitText = [expandPastedBlocks(invocationText, currentPastedBlocks), refs].filter(Boolean).join(" ");
const submitBase = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
const submitText = [submitBase, selectedTextContext].filter(Boolean).join("\n\n");
submittedSubmitText = submitText;
const structuredInput = [expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(" ");
let structured: StructuredInvocationSubmit | undefined = trimmedDraft.invocations.length > 0 ? {
display: [invocationText, displayRefs].filter(Boolean).join(invocationText && displayRefs ? " " : ""),
@@ -2199,6 +2169,7 @@ export function Composer({
attachmentSubmissionId = prepared.submissionId;
structured = prepared.structured;
}
submittedStructured = structured;
// Repeated compaction asks the owner for its current operation receipt;
// queueing it would unexpectedly start another compaction after this one.
if (running && !(maintenanceActive && !structured && isCompactCommand(submitText))) {
@@ -2265,6 +2236,10 @@ export function Composer({
}
if (queueOnly || receipt.disposition === "queued_followup") showToast(t("runtime.queued"), "info");
} catch (error) {
// A transient fence keeps the pending request and retries it
// briefly: the message stays visibly pending instead of surfacing
// an error the user cannot act on.
if (holdTransientGuidance(error, { display: guidanceText, submit: guidanceSubmitText, structured, turnId })) return;
if (followupNotSubmitted(error)) attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
// Registration still needs reconciliation, but no inbox receipt exists before enqueue starts.
if (unresolvedRequest && (!enqueueAttempted || followupNotSubmitted(error))) pendingFollowups.clear(submitPendingKey, unresolvedRequest);
@@ -2285,6 +2260,33 @@ export function Composer({
attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
if (!persistentDraft && followupDraftFingerprint(submitDraftKey) === submittedDraft) clearSubmittedDraft(submitDraftKey);
} catch (error) {
// A submit that raced a session switch is refused by the serve's
// expected-session fence. Hold it visibly and retry once the route
// settles instead of reporting a failure the user cannot act on.
if (holdTransientGuidance(error, {
display: submittedDisplayText || trimmedText,
submit: submittedSubmitText || trimmedText,
structured: submittedStructured,
})) return;
// A busy-window submit was durably queued instead of starting a turn:
// show the queue entry immediately and confirm the message left the
// composer, rather than reporting a conflict.
const queued = queuedFollowupOutcome(error);
if (queued) {
updatePendingGuidanceForDraft(submitDraftKey, (items) => items.some((item) => item.id === queued.itemId) ? items : [...items, {
id: queued.itemId,
text: trimmedText.slice(0, 120),
submitText: "",
intent: "followup",
state: "queued",
source: "desktop",
paused: queued.paused,
}]);
if (ownsDraft() && followupDraftFingerprint(submitDraftKey) === submittedDraft) clearSubmittedDraft(submitDraftKey);
setGuidanceRetryNonce((value) => value + 1);
showToast(t("runtime.queued"), "info");
return;
}
if (definitelyNotAccepted(error) || followupNotSubmitted(error)) attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
if (savedInput.target && modelApplicationError(error)) savedInput.reportSubmissionError(error);
else if (savedInput.target) showToast(formatInboxError(error, locale), "warn");
@@ -1,13 +1,14 @@
import { useId, useState } from "react";
import { useI18n } from "../lib/i18n";
import { presentError } from "../lib/errorPresentation";
import type { WireEvent } from "../lib/types";
/** Inline-safe error surface, including inside paragraphs and existing banners. */
export function ErrorMessage({ error, summary, onInspect }: { error: unknown; summary?: string; onInspect?: () => void }) {
export function ErrorMessage({ error, summary, diagnostic, onInspect }: { error: unknown; summary?: string; diagnostic?: WireEvent["diagnostic"]; onInspect?: () => void }) {
const { t, locale } = useI18n();
const id = useId();
const [expandedError, setExpandedError] = useState<{ error: unknown } | null>(null);
const presentation = presentError(error, t, locale);
const presentation = presentError(error, t, locale, diagnostic);
const expanded = expandedError !== null && expandedError.error === error;
const title = summary || presentation.summary;
const detail = error == null || error === "" ? "" : presentation.detail || (summary ? presentation.summary : "");
@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useI18n } from "../lib/i18n";
import type { GoalLifecycleView } from "../lib/types";
@@ -26,23 +27,65 @@ export function GoalLifecycleActions({
|| (goalView?.phase === "active" && goalView.activation === "disarmed")
|| (!goalView && goalStatus === "blocked");
const editGoal = () => {
const [draft, setDraft] = useState<{ objective: string; rounds: string; invalid: boolean } | null>(null);
const editButtonRef = useRef<HTMLButtonElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (wasOpen.current && !draft) editButtonRef.current?.focus();
wasOpen.current = draft !== null;
}, [draft]);
const openEditor = () => {
if (!goalView) return;
const objective = window.prompt(t("composer.goalEditObjective"), goalView.objective);
if (objective === null) return;
const rawLimit = window.prompt(t("composer.goalEditMaxRounds"), goalView.maxGoalRounds?.toString() ?? "");
if (rawLimit === null) return;
const trimmedLimit = rawLimit.trim();
const parsedLimit = trimmedLimit === "" ? null : Number(trimmedLimit);
if (parsedLimit !== null && (!Number.isSafeInteger(parsedLimit) || parsedLimit <= 0)) {
window.alert(t("composer.goalEditInvalidRounds"));
setDraft({ objective: goalView.objective, rounds: goalView.maxGoalRounds?.toString() ?? "", invalid: false });
};
const submitEdit = (event: React.FormEvent) => {
event.preventDefault();
if (!draft) return;
const trimmed = draft.rounds.trim();
const limit = trimmed === "" ? null : Number(trimmed);
if (limit !== null && (!Number.isSafeInteger(limit) || limit <= 0)) {
setDraft({ ...draft, invalid: true });
return;
}
onEditGoal(objective, parsedLimit);
setDraft(null);
onEditGoal(draft.objective, limit);
};
return <>
{goalView && <button type="button" className="composer-intent-menu__stop" onClick={editGoal} disabled={disabled}>
{draft && (
<form className="goal-edit-form" data-goal-edit-form="" onSubmit={submitEdit} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setDraft(null); } }}>
<label>
{t("composer.goalEditObjective")}
<textarea
className="remote-secret-dialog__input"
rows={2}
data-goal-edit-objective=""
autoFocus
value={draft.objective}
onChange={(e) => setDraft({ ...draft, objective: e.target.value })}
/>
</label>
<label>
{t("composer.goalEditMaxRounds")}
<input
className="remote-secret-dialog__input"
data-goal-edit-rounds=""
inputMode="numeric"
value={draft.rounds}
onChange={(e) => setDraft({ ...draft, rounds: e.target.value, invalid: false })}
/>
</label>
{draft.invalid && <p className="goal-edit-form__error" role="alert" data-goal-edit-error="">{t("composer.goalEditInvalidRounds")}</p>}
<div className="goal-edit-form__actions">
<button type="button" className="btn" onClick={() => setDraft(null)}>{t("common.cancel")}</button>
<button type="submit" className="btn btn--primary" disabled={draft.objective.trim() === ""}>{t("common.save")}</button>
</div>
</form>
)}
{goalView && <button ref={editButtonRef} type="button" className="composer-intent-menu__stop" onClick={openEditor} disabled={disabled || draft !== null}>
{t("composer.taskModeEditGoal")}
</button>}
{resumable ? (
@@ -6,7 +6,7 @@ import { useT } from "../lib/i18n";
import type { HistoricalImportStatus, HistoricalSessionView, HistoricalSourceUpdateView } from "../generated/desktopContract.generated";
import type { SessionRef } from "../lib/sessionRef";
import { desktopHost } from "../lib/desktopHost";
import { useManagementT, type ManagementKey } from "../lib/managementLocale";
import { historicalFailureKey, useManagementT, type ManagementKey } from "../lib/managementLocale";
const emptyHistoricalStatus: HistoricalImportStatus = { items: [], running: false, paused: false, remaining: 0, completed: 0, blocked: 0, failed: 0 };
const historicalStatusKeys: Record<string, ManagementKey> = {
@@ -97,7 +97,7 @@ export function HistoricalImportList({ active, onOpenSession }: { active: boolea
<div className="archived-sessions__row" key={item.id}>
<span>{item.title}</span><small>{item.format}</small>
<span>{m(historicalStatusKeys[item.status] ?? "historicalNeedsAttention")}</span>
{item.errorCode && <small>{m(item.errorCode === "source_busy" ? "historicalSourceBusy" : "historicalImportFailed")}</small>}
{item.errorCode && item.errorCode !== "cancelled" && <small><ErrorMessage error={item.errorDetail ?? ""} summary={m(historicalFailureKey(item.errorCode))} /></small>}
<button className="btn btn--small" disabled={!!busy || item.status === "importing" || item.status === "queued" || item.status === "archived" || item.status === "deleted"}
onClick={() => void open(item)}>{item.session && onOpenSession ? t("history.openRestored") : m("historicalImportOpen")}</button>
{item.session && item.source && <button className="btn btn--small" disabled={!!busy} onClick={() => void checkUpdate(item)}>{t("common.retry")} · {m("historicalTitle")}</button>}
@@ -128,6 +128,7 @@ export function ProjectTree({
const compactTopics = variant === "workbench";
const creationTopics = variant === "creation";
const [tree, setTree] = useState<ProjectNode[]>([]);
const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
const treeRef = useRef<ProjectNode[]>([]);
const latestRevisionRef = useRef(0);
const shellRequestRef = useRef(0);
@@ -490,10 +491,11 @@ export function ProjectTree({
})));
await reloadRequestedProjects(projects);
} catch (err) {
// A shell snapshot is metadata-only. If it fails, the resident folder
// identity can still drive the requested canonical topic reload.
// A failed shell read can still reload topics from resident folders.
if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
if (throwOnSnapshotError) throw err;
} finally {
if (request === shellRequestRef.current) setShellStage("ready");
}
}, [applyRuntimeProjection, reloadProjectTopicLists]);
refreshRef.current = refresh;
@@ -561,6 +563,10 @@ export function ProjectTree({
return () => { shellRequestRef.current++; };
}, [refresh, refreshSignal]);
useEffect(() => {
if (shellStage !== "loading") return;
const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
}, [shellStage]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
@@ -1018,7 +1024,7 @@ export function ProjectTree({
}
}, [onTopicsChanged, refresh, tree]);
const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision });
const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision, sortMode: creationTopics ? "updated" : workbenchSortMode });
const clearProjectDrag = useCallback(() => {
setDragProjectRoot(null);
@@ -1588,7 +1594,7 @@ export function ProjectTree({
label: t("projectTree.newGroup"),
onSelect: () => organization.createGroup(node, t("projectTree.newGroup")),
},
newSessionMenuItem,
newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
...isolatedWorkspaceItems,
{
key: "rename",
@@ -1642,7 +1648,7 @@ export function ProjectTree({
: []),
];
const workbenchProjectMenuItems: ContextMenuItem[] = [
newSessionMenuItem,
newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
...(scope === "project"
? [
{
@@ -2010,6 +2016,11 @@ export function ProjectTree({
);
const renderEmptyState = () => {
if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
{shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
<span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
</div>}
</div>;
if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
return (
<div className="project-tree__empty-state">
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, lazy, Suspense, type DragEvent, type HTMLAttributes, type ReactNode } from "react";
import { Archive, FolderMinus, Pencil } from "lucide-react";
import { Archive, Clock, FolderMinus, Pencil } from "lucide-react";
import { app } from "../lib/bridge";
import { asArray } from "../lib/array";
import type { Translator } from "../lib/i18n";
@@ -144,6 +144,7 @@ type TopicRowDragProps = Pick<HTMLAttributes<HTMLDivElement>, "draggable" | "onD
export interface ProjectTreeOrganizationController {
orderFor?(folder: ProjectNode): readonly string[];
resetOrderMenuItems(folder: ProjectNode, t: Translator, closeMenu: () => void): ContextMenuItem[];
topicRow(node: ProjectNode, disabled: boolean): { className: string; props: TopicRowDragProps };
topicMenuItems(node: ProjectNode, t: Translator): ContextMenuItem[];
createGroup(folder: ProjectNode, title: string): void;
@@ -161,15 +162,20 @@ export function useProjectTreeOrganization({
refresh,
onTopicsChanged,
organizationRevision = 0,
sortMode,
bindings = app,
}: {
tree: ProjectNode[];
refresh: ProjectTreeRefresh;
onTopicsChanged?: () => Promise<void> | void;
organizationRevision?: number;
// The activity order on screen; a first manual move starts from it.
sortMode?: string;
bindings?: ProjectTreeOrganizationBindings;
}): ProjectTreeOrganizationController {
const { showToast } = useToast();
const sortModeRef = useRef(sortMode);
sortModeRef.current = sortMode;
const [ordersByKey, setOrdersByKey] = useState<Record<string, string[]>>({});
const [dragTopicID, setDragTopicID] = useState<string | null>(null);
const [dropTopic, setDropTopic] = useState<{ topicID: string; position: ProjectDropPosition } | null>(null);
@@ -313,9 +319,12 @@ export function useProjectTreeOrganization({
const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
if (bindings.UpdateSessionOrganization && dragged) {
const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
} else if (bindings.UpdateSessionOrganization && dragged) {
void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position, sortMode: sortModeRef.current })
.then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
.then(() => onTopicsChanged?.()).catch(error => { showToast(String(error), "error"); loadGroups(key, true); return refresh({ reloadAllTopics: true }); });
} else {
@@ -326,7 +335,7 @@ export function useProjectTreeOrganization({
};
props.onDragEnd = clearTopicDrag;
return { className, props };
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
const removeTopicFromGroups = useCallback((node: ProjectNode) => {
const key = projectTreeOrganizationKey(node);
@@ -340,6 +349,13 @@ export function useProjectTreeOrganization({
return {
orderFor(folder) { return ordersByKey[projectTreeOrganizationKey(folder)] ?? []; },
resetOrderMenuItems(folder, t, closeMenu) {
const key = projectTreeOrganizationKey(folder);
return (ordersByKey[key]?.length ?? 0) > 0 ? [{
key: "reset-manual-order", icon: <Clock size={13} />, label: t("projectTree.resetManualOrder"),
onSelect: () => { closeMenu(); mutateGroups(key, (groups) => groups, { kind: "reset-order" }); },
}] : [];
},
topicRow,
topicMenuItems(node, t) {
if (!(groupsRef.current[projectTreeOrganizationKey(node)] ?? []).some((group) => projectTreeGroupContainsNode(group, node))) return [];
@@ -45,7 +45,9 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
const visiblePrompt = approval || ask || mcpInteraction;
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
// runtimeEpoch stays optional: a serve fences on it only when it has one,
// and a CLI serve never binds desktop routing metadata.
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
));
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
@@ -6,7 +6,7 @@ import { app } from "../lib/bridge";
import type { SessionTakeoverView, TabMeta } from "../lib/types";
import type { HistoricalSourceUpdateView, SessionPreparationView } from "../generated/desktopContract.generated";
import { historicalPreparationSnapshot, reconcileHistoricalPreparation, subscribeHistoricalPreparation, type DesktopNavigationIntent } from "../app-runtime/desktopNavigationOwner";
import { useManagementT } from "../lib/managementLocale";
import { historicalFailureKey, useManagementT } from "../lib/managementLocale";
/**
* SessionTakeoverDialog confirms taking a lease-blocked session over from the
@@ -132,15 +132,17 @@ export type HistoricalSessionBannerProps = {
tab?: TabMeta;
navigate(intent: DesktopNavigationIntent): Promise<void>;
captureNavigation?(): () => boolean;
openRecoveryDetails?(): void;
};
export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: HistoricalSessionBannerProps) {
type HistoricalFailure = { summary: string; detail?: unknown };
export function HistoricalSessionBanners({ tab, navigate, captureNavigation, openRecoveryDetails }: HistoricalSessionBannerProps) {
const t = useT();
const m = useManagementT();
const activeRef = tab?.session ?? (tab?.sessionId ? { hostId: "local", sessionId: tab.sessionId } : undefined);
const preparation = useSyncExternalStore(subscribeHistoricalPreparation, historicalPreparationSnapshot);
const [update, setUpdate] = useState<HistoricalSourceUpdateView | null>(null);
const [busy, setBusy] = useState(false);
const [updateError, setUpdateError] = useState("");
const [updateError, setUpdateError] = useState<HistoricalFailure | null>(null);
const updateOperation = useRef(0);
const [cancellingOperationId, setCancellingOperationId] = useState("");
const cancellingOperationRef = useRef("");
@@ -158,7 +160,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
let current = true;
updateOperation.current++;
setUpdate(null);
setUpdateError("");
setUpdateError(null);
setBusy(false);
if (!activeSessionId || activeHostId !== "local" || !app.CheckHistoricalSourceUpdate) return () => { current = false; };
const ref = { hostId: activeHostId, sessionId: activeSessionId };
@@ -200,7 +202,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
const navigationCurrent = captureNavigation?.() ?? (() => activeKeyRef.current === expectedActive);
const current = () => mounted.current && operation === updateOperation.current && activeKeyRef.current === expectedActive && navigationCurrent();
setBusy(true);
setUpdateError("");
setUpdateError(null);
try {
let view = await start();
while (current() && !terminalPreparation.has(view.status)) {
@@ -209,13 +211,17 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
view = await app.GetSessionPreparation(view.operationId);
}
if (!current()) return;
if (view.status === "ready" && view.target) {
await navigate({ kind: "canonical-session", ref: view.target });
} else {
setUpdateError(m(view.errorCode === "source_busy" ? "historicalSourceBusy" : "historicalImportFailed"));
if (view.status !== "ready" || !view.target) {
setUpdateError({ summary: m(historicalFailureKey(view.errorCode)), detail: view.errorDetail });
return;
}
} catch {
if (current()) setUpdateError(m("historicalImportFailed"));
try {
await navigate({ kind: "canonical-session", ref: view.target });
} catch (error) {
if (current()) setUpdateError({ summary: m("historicalOpenFailed"), detail: error });
}
} catch (error) {
if (current()) setUpdateError({ summary: m("historicalImportFailed"), detail: error });
}
finally { if (mounted.current && operation === updateOperation.current) setBusy(false); }
};
@@ -234,27 +240,34 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
}
};
const failureNotice = (failure: HistoricalFailure) => <ErrorMessage error={failure.detail ?? ""} summary={failure.summary} />;
const recoveryDetails = openRecoveryDetails
? <button type="button" className="btn btn--small btn--ghost" onClick={openRecoveryDetails}>{m("historicalRecoveryDetails")}</button> : null;
if (preparation) {
const waiting = preparation.status === "queued" || preparation.status === "preparing";
return <div className={`banner ${waiting ? "banner--warning" : "banner--error"} banner--actionable`} role="status">
<span className="banner__msg">{m("historicalImporting")}: {preparation.session.title || preparation.session.topicId || m("historicalTitle")}</span>
<span className="banner__hint">{m(preparation.status === "queued" ? "historicalQueued" : preparation.status === "preparing" ? "historicalImporting" : "historicalImportFailed")}</span>
<span className="banner__hint">{waiting ? m(preparation.status === "queued" ? "historicalQueued" : "historicalImporting")
: failureNotice({ summary: m(historicalFailureKey(preparation.errorCode)), detail: preparation.errorDetail })}</span>
<span className="banner__spacer" />
{waiting && <button type="button" className="btn btn--small" disabled={cancellingOperationId === preparation.operationId} onClick={() => void cancelPreparation()}>{t("common.cancel")}</button>}
{!waiting && recoveryDetails}
{!waiting && preparation.retryable && <button type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: preparation.session })}>{t("common.retry")}</button>}
</div>;
}
if (historicalSource) return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
<span className="banner__msg">{tab?.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
<span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
<span className="banner__hint">{updateError ? failureNotice(updateError) : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
<span className="banner__spacer" />
{updateError && recoveryDetails}
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" disabled={busy} onClick={() => void importSource()}>{m("historicalImportOpen")}</button>
</div>;
if (!update) return null;
return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
<span className="banner__msg">{m("historicalSourceUpdated")}</span>
{(updateError || busy) && <span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m("historicalImporting")}</span>}
{(updateError || busy) && <span className="banner__hint">{updateError ? failureNotice(updateError) : m("historicalImporting")}</span>}
<span className="banner__spacer" />
{updateError && recoveryDetails}
<button type="button" className="btn btn--small" disabled={busy} onClick={() => void importUpdate()}>{m("historicalImportOpen")} · {m("branch")}</button>
<button type="button" className="btn btn--small" disabled={busy} onClick={dismissUpdate}>{t("updater.dismiss")}</button>
</div>;
@@ -6652,6 +6652,19 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
setPathMessage(t("settings.hooksJsonClipboardUnavailable"));
}
};
const approve = async () => {
setBusy(true);
setErr(null);
try {
await app.TrustProjectHooksForRoot(view?.projectRoot?.trim() ?? "");
await load(scope);
onChanged();
} catch (e) {
setErr(String((e as Error)?.message ?? e));
} finally {
setBusy(false);
}
};
const save = async () => {
setBusy(true);
setErr(null);
@@ -6694,6 +6707,12 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
title={t("settings.hooks")}
description={scope === "project" ? t("settings.hooksProjectHint") : t("settings.hooksGlobalHint")}
>
{view && scope === "project" && !view.trusted && view.hooks.length > 0 && (
<div className="banner banner--warning" role="status">
<span>{t("settings.hooksAwaitingApproval")}</span>
<button className="btn btn--small" disabled={busy} onClick={() => void approve()}>{t("settings.hooksApprove")}</button>
</div>
)}
{view && (
<div className="hooks-json-panel">
<div className="hooks-json-panel__head">
@@ -239,6 +239,23 @@ function ChatSession(props: TranscriptProps & { sessionKey: string }) {
if (result === "loaded" && current()) scroll.toBottom();
} finally { release(); }
};
const newerArmed = useRef(true);
const autoNewer = useRef<() => void>(() => undefined);
autoNewer.current = () => {
if (!onLoadNewerHistory || selectionInsideTranscript()) return;
newerArmed.current = false;
const intent = ++navigationIntent.current;
const generation = lifetime.current;
let reading = false;
const release = scroll.subscribeReaderIntent(() => { reading = true; });
const current = () => !reading && intent === navigationIntent.current && generation === lifetime.current && !selectionRef.current();
scroll.stopFollowing();
void loadPage("newer", "auto-fill", current).finally(release);
};
useEffect(() => {
if (!hasNewerHistory || !position.following) { newerArmed.current = true; return; }
if (newerArmed.current && !hydrating && !loadingNewerHistory && !newerHistoryError) autoNewer.current();
}, [hasNewerHistory, position.following, hydrating, loadingNewerHistory, newerHistoryError]);
useEffect(() => () => jump.dispose(), [jump]);
useEffect(() => desktopHost().native.onServiceState(state => {
if (state.phase === "stopping" || state.phase === "exited") {
@@ -48,3 +48,8 @@ export function persistentSnapshot(draft: ComposerDraft): PersistentComposerDraf
selectedTextRefs: draft.selectedTextRefs,
};
}
export function composerDraftFingerprint(draft: ComposerDraft): string {
return JSON.stringify([draft.text, draft.invocations, draft.attachments, draft.workspaceRefs,
draft.sessionRefs, draft.selectedTextRefs, draft.pastedBlocks]);
}
@@ -19921,7 +19921,7 @@
},
{
"cancellation": "before-dispatch",
"domain": "desktop/remote_tab.go",
"domain": "desktop/remote_submit.go",
"name": "SubmitRemoteTab",
"owner": "App.SubmitRemoteTab",
"params": [
@@ -19942,7 +19942,7 @@
"resolver": "App.SubmitRemoteTab"
},
"sources": [
"desktop/remote_tab.go"
"desktop/remote_submit.go"
]
},
{
@@ -20055,7 +20055,7 @@
},
{
"cancellation": "before-dispatch",
"domain": "desktop/remote_tab.go",
"domain": "desktop/remote_submit.go",
"name": "SubmitRemoteTabWithSubmission",
"owner": "App.SubmitRemoteTabWithSubmission",
"params": [
@@ -20080,7 +20080,7 @@
"resolver": "App.SubmitRemoteTabWithSubmission"
},
"sources": [
"desktop/remote_tab.go"
"desktop/remote_submit.go"
]
},
{
@@ -31177,6 +31177,13 @@
"kind": "string"
}
},
{
"name": "errorDetail",
"optional": true,
"type": {
"kind": "string"
}
},
{
"name": "session",
"optional": true,
@@ -40676,6 +40683,13 @@
"type": {
"kind": "string"
}
},
{
"name": "sortMode",
"optional": true,
"type": {
"kind": "string"
}
}
]
},
@@ -40788,6 +40802,13 @@
"kind": "string"
}
},
{
"name": "errorDetail",
"optional": true,
"type": {
"kind": "string"
}
},
{
"name": "retryable",
"type": {
@@ -45032,6 +45053,13 @@
"type": {
"kind": "string"
}
},
{
"name": "transportCode",
"optional": true,
"type": {
"kind": "string"
}
}
]
},
@@ -3,7 +3,7 @@
export const DESKTOP_PROTOCOL_VERSION = 11;
export const DESKTOP_CONTRACT_DIGEST = "sha256:857039e0868d57e1e367b080f009abd2d21baa856ae5d57e55fc8c3b1cef5734";
export const DESKTOP_CONTRACT_DIGEST = "sha256:22737aab34fb4b66050efd11ac4b4ac83623d62cf9e5c870b81e65109fde7398";
export const DESKTOP_COMMANDS = [
"AIRenameSession",
@@ -2595,6 +2595,7 @@ export interface HistoricalSessionView {
format: string;
status: string;
errorCode?: string;
errorDetail?: string;
session?: SessionRef | null;
source?: SessionSourceRef | null;
}
@@ -4370,6 +4371,7 @@ export interface SessionOrganizationMutation {
position?: string;
groupId?: string;
title?: string;
sortMode?: string;
}
export interface SessionOrganizationSnapshot {
@@ -4393,6 +4395,7 @@ export interface SessionPreparationView {
revision: number;
target?: SessionRef | null;
errorCode?: string;
errorDetail?: string;
retryable: boolean;
}
@@ -5202,6 +5205,7 @@ export interface FailureDiagnostic {
providerDisplayName?: string;
protocol?: string;
requestPath?: string;
transportCode?: string;
}
export interface MCPAppPresentation {
+2 -2
View File
@@ -4672,10 +4672,10 @@ function makeMockApp(): MockAppBindings {
hookSettings[key].hooks = JSON.parse(JSON.stringify(hooks)) as HookConfigView[];
},
async TrustProjectHooks() {
// Compatibility no-op: project hooks are enabled automatically.
hookSettings.project.trusted = true;
},
async TrustProjectHooksForRoot(_projectRoot: string) {
// Compatibility no-op: project hooks are enabled automatically.
hookSettings.project.trusted = true;
},
async SetDefaultModel(ref: string) {
settings.defaultModel = ref;
+27 -10
View File
@@ -36,7 +36,8 @@ const sameReferences = (a: readonly unknown[] = [], b: readonly unknown[] = [])
a.length === b.length && a.every((item, index) => item === b[index]);
const shallowSame = (a: object, b: object) => Object.keys(a).length === Object.keys(b).length
&& Object.entries(a).every(([key, value]) => value === (b as Record<string, unknown>)[key]);
const foldViews = new Map<string, Set<string>>();
const HEAD_KEY = "history-head";
const foldViews = new Map<string, Map<string, boolean>>();
const emptyChildren: readonly Extract<ChatNode, { kind: "tool" }>[] = [];
function proxyAuditCall(item: Item): string | undefined {
if (item.kind !== "notice" || item.code !== "capability_proxy_audit") return undefined;
@@ -109,11 +110,12 @@ export class ChatSource implements ChatViewSource {
private epoch = 0;
private input?: ChatInput;
private status: ChatStatus = { running: false, hydrating: true, hasOlder: false, loadingOlder: false };
private opened = new Set<string>();
private choices = new Map<string, boolean>();
private headAnchor?: string;
private displayKeyBySubmission = new Map<string, string>();
private displayMessageBySubmission = new Map<string, string>();
private displayKeyByMessage = new Map<string, string>();
constructor(readonly sessionKey: string) { this.opened = new Set(foldViews.get(sessionKey)); }
constructor(readonly sessionKey: string) { this.choices = new Map(foldViews.get(sessionKey)); }
getOrderSnapshot = () => this.order;
getNodeSnapshot = (key: string) => this.nodes.get(key);
getStatusSnapshot = () => this.status;
@@ -157,7 +159,7 @@ export class ChatSource implements ChatViewSource {
const order: string[] = [];
const present = new Set<string>();
const groups: Array<{ key: string; turn?: number; user?: Extract<Item, { kind: "user" }>; items: Item[] }> = [];
let group: (typeof groups)[number] = { key: "history-head", items: [] };
let group: (typeof groups)[number] = { key: HEAD_KEY, items: [] };
groups.push(group);
for (const { item, local } of itemsWithLocalSubmissions(input)) {
if (item.kind === "user") {
@@ -167,6 +169,7 @@ export class ChatSource implements ChatViewSource {
} else group.items.push(item);
}
const groupKeys = new Set(groups.map(current => current.key));
this.adoptHeadChoice(groups);
for (const current of groups) {
if (!current.user && !current.items.length) continue;
const turnKey = current.key;
@@ -207,11 +210,13 @@ export class ChatSource implements ChatViewSource {
const hasProcess = current.items.some(item => item.kind === "tool" || item.kind === "phase" ||
item.kind === "assistant" && (item !== answer && item.text.trim() || item.reasoning.trim()));
const hasTrailingWork = current.items.slice(answerIndex + 1).some(item => item.kind === "tool" || item.kind === "phase" || item.kind === "assistant");
const foldable = Boolean(hasProcess && current.user && answer && !hasTrailingWork && !active && !failed && !current.user.failed);
// Every ended turn can fold by hand; only a settled one starts folded.
const settled = Boolean(answer && !hasTrailingWork && !failed && !current.user?.failed);
const foldable = Boolean(hasProcess && !active);
const allCalls = current.items.filter((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool");
const calls = allCalls.filter(item => !item.parentId);
const subagentCount = calls.filter(item => ["task", "read_only_task", "parallel_tasks", "fleet", "subagent"].includes(item.name)).length;
add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !this.opened.has(turnKey),
add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !(this.choices.get(turnKey) ?? !settled),
toolCallCount: calls.length - subagentCount, subagentCount,
messageCount: current.items.filter(item => item.kind === "assistant" && item !== answer && item.text.trim()).length,
failureCount: calls.filter(item => item.status === "error" || item.error).length });
@@ -264,10 +269,20 @@ export class ChatSource implements ChatViewSource {
this.children.set(key, next); this.dirty.add(`${key}:children`);
}
}
for (const key of this.opened) if (!groupKeys.has(key)) this.opened.delete(key);
for (const key of this.choices.keys()) if (!groupKeys.has(key)) this.choices.delete(key);
if (!sameKeys(this.order, order)) { this.order = order; this.orderDirty = true; }
recordFrontendDiagnostic("transcript", "presentation", this.presentationStats());
}
// The head group is a turn whose user message sits on an older page; once
// that page loads the same items sit under the user's key.
private adoptHeadChoice(groups: ReadonlyArray<{ key: string; user?: unknown; items: readonly Item[] }>) {
const head = groups[0];
const anchor = this.headAnchor;
this.headAnchor = head.key === HEAD_KEY ? head.items[0]?.id : undefined;
if (!anchor || !this.choices.has(HEAD_KEY) || (head.key === HEAD_KEY && head.items[0]?.id === anchor)) return;
const next = groups.find(current => current.user && current.items.some(item => item.id === anchor));
if (next) this.choices.set(next.key, this.choices.get(HEAD_KEY)!);
}
private userDisplayKey(item: Extract<Item, { kind: "user" }>, local?: LocalSubmission, handoffs?: ChatInput["visibleSubmissionHandoffs"]): string {
if (local) {
const key = `submission:${local.submissionId}`;
@@ -308,10 +323,12 @@ export class ChatSource implements ChatViewSource {
toggleProcess(turnKey: string) {
const node = this.nodes.get(`${turnKey}:process`);
if (node?.kind !== "process") return;
if (node.collapsed) this.opened.add(turnKey); else this.opened.delete(turnKey);
foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Set(this.opened));
if (!node.foldable) return;
const open = node.collapsed;
this.choices.set(turnKey, open);
foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Map(this.choices));
if (foldViews.size > 100) foldViews.delete(foldViews.keys().next().value!);
this.put({ ...node, collapsed: node.foldable && !this.opened.has(turnKey) });
this.put({ ...node, collapsed: !open });
this.flush();
}
toolChildren(id: string) { return this.children.get(id) ?? emptyChildren; }
@@ -0,0 +1,42 @@
import type { KeyboardEvent } from "react";
import { sha256 } from "./attachDedup";
function fileKey(file: File): string {
return `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
}
export function clipboardFiles(data: DataTransfer): File[] {
const files = Array.from(data.files);
const seen = new Set(files.map(fileKey));
for (const item of Array.from(data.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (!file) continue;
const key = fileKey(file);
if (seen.has(key)) continue;
seen.add(key);
files.push(file);
}
return files;
}
export function clipboardHasImageHint(data: DataTransfer): boolean {
const imageType = (value: string) => {
const type = value.toLowerCase();
return type.startsWith("image/") || type.includes("png") || type.includes("jpeg") || type.includes("jpg") || type.includes("tiff");
};
return Array.from(data.items).some((item) => imageType(item.type)) || Array.from(data.types).some(imageType);
}
export function isPasteShortcut(e: KeyboardEvent<HTMLElement>): boolean {
return e.key.toLowerCase() === "v" && (e.metaKey || e.ctrlKey) && !e.altKey;
}
export async function dataURLHash(dataUrl: string): Promise<string> {
try {
const res = await fetch(dataUrl);
return sha256(await res.blob());
} catch {
return "";
}
}
@@ -4,12 +4,12 @@ import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./
import type { Item } from "./useController";
import { readPauseItem } from "./readPause";
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, diagnostic?: HistoryMessage["diagnostic"]): { items: Item[]; seq: number } {
if (quietTranscriptNoticeKey(rawText, code)) return { items, seq };
const text = localizedNoticeText(rawText, code);
if (quietTranscriptNoticeKey(text, code)) return { items, seq };
const trimmedDetail = detail?.trim();
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
return { items: [...items, { kind: "notice", id, level, text, ...(diagnostic ? { diagnostic } : {}), ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
}
export function errorMessage(err: unknown): string {
@@ -190,5 +190,5 @@ export function historyNoticeItems(m: HistoryMessage, id: string): Item[] {
if (quietTranscriptNoticeKey(text, m.code)) return [];
const detail = m.detail?.trim();
return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text,
...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
...(m.diagnostic ? { diagnostic: m.diagnostic } : {}), ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
}
@@ -1,4 +1,5 @@
import { t, getLocale, type DictKey, type Translator, type Locale } from "./i18n";
import type { WireEvent } from "./types";
export interface ErrorPresentation { summary: string; detail: string }
@@ -24,6 +25,7 @@ const codeKeys: Record<string, DictKey> = {
invalid_api_key: "error.auth", insufficient_quota: "error.quota", rate_limit_exceeded: "error.rateLimit",
stream_interrupted: "error.interrupted", empty_response: "error.empty", stale_generation: "error.conflict",
provider_connection: "error.connection", cancelled: "error.cancelled",
transport_protocol: "error.transportProtocol",
};
// Narrow legacy matches bridge errors from older services and OS libraries.
@@ -61,8 +63,11 @@ function statusKey(status: number): DictKey | undefined {
if (status === 400 || status === 422) return "error.invalidInput";
}
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale()): ErrorPresentation {
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale(), diagnostic?: WireEvent["diagnostic"]): ErrorPresentation {
const detail = errorDetail(error).trim();
if (diagnostic?.kind === "transport_protocol") {
return { summary: translate("error.transportProtocol"), detail: [detail, diagnostic.transportCode ? `HTTP/2: ${diagnostic.transportCode}` : ""].filter(Boolean).join("\n") };
}
const structured = error && typeof error === "object" ? error as { code?: unknown; status?: unknown } : undefined;
const code = typeof structured?.code === "string" ? structured.code : detail;
const explicitStatus = typeof structured?.status === "number" ? structured.status : 0;
+1 -1
View File
@@ -102,7 +102,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
continue;
}
if (m.content.trim() !== "" || m.decisionReceipt) {
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt, m.diagnostic);
items = next.items;
seq = next.seq;
}
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
return "empty";
}
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
input.dispatch({ type: "history_older_error", error: "history identity changed" });
return "empty";
}
@@ -27,6 +27,16 @@ export function applyHydrateErrorState<
};
}
/** The recovery detail for a failed history read: the summary, then the reader's own identity and cause. */
export function hydrateFailureDetail(summary: string, cause: unknown): string {
const message = (cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "").trim();
const failure = cause as { stage?: unknown; reason?: unknown } | undefined;
const identity = cause instanceof Error && typeof failure?.stage === "string" && typeof failure.reason === "string"
? `${failure.stage}.${failure.reason}` : "";
const line = [identity, message].filter(Boolean).join(": ");
return line ? `${summary}\n${line}` : summary;
}
export function hydratePlaceholderItems<TItem>(
optionsItems: TItem[] | undefined,
): TItem[] | undefined {
+4
View File
@@ -20,6 +20,7 @@ const CODE_INDEX = {
workspace_start_failed: 12,
inbox_not_submitted: 16,
image_attachment_unreadable: 17,
inbox_target_transient: 18,
} as const;
type InboxErrorCode = keyof typeof CODE_INDEX;
@@ -47,6 +48,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
"Cancel failed: {error}",
"The message was not sent. Refresh the session and try again",
"The image could not be read. Re-add it or try again",
"The session is switching. The message will be sent automatically.",
],
zh: [
"收件箱已暂停",
@@ -67,6 +69,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
"取消失败:{error}",
"消息未发送,请刷新会话后重试",
"图片读取失败,请重新添加或重试",
"会话正在切换,消息会自动发出。",
],
"zh-TW": [
"收件匣已暫停",
@@ -87,6 +90,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
"取消失敗:{error}",
"訊息未傳送,請重新整理會話後重試",
"圖片讀取失敗,請重新加入或重試",
"工作階段正在切換,訊息會自動送出。",
],
};
@@ -49,6 +49,14 @@ const messages = {
historicalSourceBusy: ["其他实例正在使用此会话,关闭后可重试。", "其他實例正在使用此對話,關閉後可重試。", "In use by another instance. Close it and retry."],
historicalImportFailed: ["导入失败,原件已保留。可重试或查看恢复详情。", "匯入失敗,原件已保留。可重試或檢視恢復詳情。", "Import failed. Original files are preserved; retry or review recovery details."],
historicalAvailable: ["尚未导入", "尚未匯入", "Not imported"],
historicalFailureWorkspaceConflict: ["会话记录的项目路径与当前项目不一致,已停止导入以免放错项目。原件已保留。", "對話記錄的專案路徑與目前專案不一致,已停止匯入以免放錯專案。原件已保留。", "The conversation records a different project folder than this project, so it was not imported. Original files are preserved."],
historicalFailureTooLarge: ["会话历史超过安全回放上限,无法导入。原件已保留。", "對話歷史超過安全重播上限,無法匯入。原件已保留。", "The conversation history exceeds the safe replay limit and cannot be imported. Original files are preserved."],
historicalFailureDamaged: ["会话历史已损坏或不完整,无法导入。原件已保留。", "對話歷史已損壞或不完整,無法匯入。原件已保留。", "The conversation history is damaged or incomplete and cannot be imported. Original files are preserved."],
historicalFailureUnsupported: ["会话由不支持的存储格式写入,无法导入。原件已保留。", "對話由不支援的儲存格式寫入,無法匯入。原件已保留。", "The conversation was written in an unsupported storage format. Original files are preserved."],
historicalFailureStateConflict: ["导入记录与已登记的会话冲突,已停止以免覆盖。原件已保留。", "匯入記錄與已登記的對話衝突,已停止以免覆寫。原件已保留。", "The import conflicts with an already registered conversation and was stopped. Original files are preserved."],
historicalFailureSourceChanged: ["导入期间原始会话发生了变化,请重试。", "匯入期間原始對話發生了變化,請重試。", "The original conversation changed during import. Retry."],
historicalOpenFailed: ["已导入,但打开会话失败。可重试。", "已匯入,但開啟對話失敗。可重試。", "Imported, but the conversation could not be opened. Retry."],
historicalRecoveryDetails: ["查看恢复详情", "檢視恢復詳情", "Review recovery details"],
historicalImportToSend: ["导入后才能继续发送消息,原始文件会保留。", "匯入後才能繼續傳送訊息,原始檔案會保留。", "Import this conversation to keep sending messages. The original file is kept."],
historicalSourceUpdated: ["旧来源有更新,可导入为独立分支", "舊來源有更新,可匯入為獨立分支", "Historical source changed. Import as a separate branch."],
historicalQueued: ["排队中", "排隊中", "Queued"],
@@ -62,6 +70,18 @@ const messages = {
branch: ["分支", "分支", "Branch"],
} as const;
export type ManagementKey = keyof typeof messages;
const historicalFailureKeys: Record<string, ManagementKey> = {
source_busy: "historicalSourceBusy",
workspace_conflict: "historicalFailureWorkspaceConflict",
history_too_large: "historicalFailureTooLarge",
history_damaged: "historicalFailureDamaged",
unsupported_version: "historicalFailureUnsupported",
state_conflict: "historicalFailureStateConflict",
target_changed: "historicalFailureSourceChanged",
};
export function historicalFailureKey(code?: string): ManagementKey {
return (code && historicalFailureKeys[code]) || "historicalImportFailed";
}
export function useManagementT() {
const { locale } = useI18n();
return useCallback((key: ManagementKey, vars?: Record<string, string | number>) => {
@@ -45,6 +45,24 @@ export async function confirmFollowup(binding: AppBindings, request: PendingFoll
return receipt;
}
// A busy-window submit answers with the durable queue receipt: the desktop
// queued the message instead of starting a turn. The receipt rides the bridge
// error data so the composer can show the queue entry immediately.
export type QueuedFollowupOutcome = { itemId: string; disposition: string; position: number; paused: boolean };
export function queuedFollowupOutcome(error: unknown): QueuedFollowupOutcome | undefined {
const data = (error as { data?: { queuedFollowup?: unknown } } | undefined)?.data?.queuedFollowup;
if (!data || typeof data !== "object") return undefined;
const receipt = data as Partial<QueuedFollowupOutcome>;
if (typeof receipt.itemId !== "string" || receipt.itemId === "") return undefined;
return {
itemId: receipt.itemId,
disposition: typeof receipt.disposition === "string" && receipt.disposition ? receipt.disposition : "queued_followup",
position: typeof receipt.position === "number" ? receipt.position : 0,
paused: receipt.paused === true,
};
}
export function followupNotSubmitted(error: unknown): boolean {
const outcome = submissionOutcome(error);
if (outcome) return outcome === "not_accepted";
Loaded 100 of 824 files, more files were not shown because too many files have changed in this diff. Show more