Compare commits

...
346 Commits
Author SHA1 Message Date
YHH e81658de81 Merge pull request #14939 from esengine/fx/notarize-staple-retry
fix(release): retry stapling when the connection to Apple drops
2026-11-02 00:27:53 -08:00
esengine 15a922a53c fix(release): retry stapling when the connection to Apple drops 2026-11-02 00:16:22 -08:00
YHH d05ef8a4f2 Merge pull request #14934 from esengine/fx/1x-protocol-compat-order
fix(provider): order compatibility evidence by request sequence, not clock readings
2026-11-01 22:07:52 -08:00
esengine 5154991f34 fix(provider): order compatibility evidence by request sequence, not clock readings 2026-11-01 21:50:02 -08:00
YHH 27113eb47d Merge pull request #14933 from esengine/docs/prepare-v1.39.11
docs(release): prepare v1.39.11 notes
2026-11-01 21:25:09 -08:00
esengine 65e1617575 docs(release): prepare v1.39.11 notes 2026-11-01 20:55:32 -08:00
YHH fae12d3ad5 Merge pull request #14928 from esengine/fx/1x-python-probe
fix(shellsafe): python -v is import tracing, not a version probe
2026-11-01 19:38:18 -08:00
esengine fbe60bb0a3 fix(shellsafe): python -v is import tracing, not a version probe
python3 -v script.py runs the script, but the read-only table lowercased the
second word and listed -v, so the call was allowed without approval. Match
python's version probes by exact spelling (--version, -V) at all three lookup
sites through one helper.

GHSA-qrrg-f72x-hx7r
2026-11-01 19:08:57 -08:00
YHH 0809983446 Merge pull request #14903 from esengine/dependabot/go_modules/main-v2/go-1x-3cca210df6
chore(deps): bump the go-1x group with 6 updates
2026-11-01 04:57:41 -08:00
esengine e5ea0f9e2f chore(deps): tidy the desktop module for the go-1x group bump 2026-11-01 04:27:50 -08:00
dependabot[bot] 53e1242122 chore(deps): bump the go-1x group with 6 updates
Bumps the go-1x group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/Microsoft/go-winio](https://github.com/Microsoft/go-winio) | `0.6.2` | `0.6.4` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.2` | `4.10.4` |
| [github.com/godbus/dbus/v5](https://github.com/godbus/dbus) | `5.2.2` | `5.2.4` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.30` | `0.0.31` |
| [github.com/pkg/sftp](https://github.com/pkg/sftp) | `1.13.11` | `1.13.12` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.48.0` | `0.49.0` |


Updates `github.com/Microsoft/go-winio` from 0.6.2 to 0.6.4
- [Release notes](https://github.com/Microsoft/go-winio/releases)
- [Commits](https://github.com/Microsoft/go-winio/compare/v0.6.2...v0.6.4)

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

Updates `github.com/godbus/dbus/v5` from 5.2.2 to 5.2.4
- [Release notes](https://github.com/godbus/dbus/releases)
- [Commits](https://github.com/godbus/dbus/compare/v5.2.2...v5.2.4)

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

Updates `github.com/pkg/sftp` from 1.13.11 to 1.13.12
- [Release notes](https://github.com/pkg/sftp/releases)
- [Commits](https://github.com/pkg/sftp/compare/v1.13.11...v1.13.12)

Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: github.com/Microsoft/go-winio
  dependency-version: 0.6.4
  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.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/godbus/dbus/v5
  dependency-version: 5.2.4
  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.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/pkg/sftp
  dependency-version: 1.13.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:57:58 +00:00
YHH 493c6811af Merge pull request #14839 from esengine/fx/compaction-far-over-window
fix(agent): keep partial fold progress so a session far over the window can be brought back
2026-10-30 05:26:11 -07:00
esengine 4eeca4a58c fix(agent): keep partial fold progress so a session far over the window can be brought back
An automatic fold was discarded whenever its result was still at or above the
physical ceiling. A view several times the window can never get under it in one
summary request, so every digest was thrown away, the source kept growing and
compaction never recovered; only a manual /compact (which skips that check)
made progress.

A candidate that strictly shrinks the view is now accepted for every trigger,
and a pressure fold over the ceiling gets the same batch allowance as a manual
one, so repeated attempts converge.
2026-10-30 04:33:17 -07:00
YHH 3f1008d4ab Merge pull request #14228 from SivanCola/fix/historical-image-replay
fix: unblock text turns after image failures / 修复历史图片阻断文字对话及错误详情丢失
2026-10-27 23:35:22 -07:00
SivanCola 19abe6a878 fix: deduplicate image replay warnings within each turn
Problem: each sampling round persisted another warning for the same failed
historical image, growing history throughout a tool turn.

Root cause: image preprocessing correctly retries failures per request, but
notice emission had no turn-scoped ownership or cause identity.

Fix: bind a synchronized set of redacted causes to admitted turn contexts.
Native attachment and image-understanding failures share this notice gate;
each request still retries image processing and fresh turns reset the set.
Document the warning lifetime without changing persisted formats or model
request content.

Verification: full internal/control suite passed (242.7s), focused image
regressions passed under race detection, pinned golangci-lint v2.12.2 found
0 issues, repolint and docs/cache impact checks passed. Two-round tool-turn
tests verify one persisted notice, continued degraded requests, and reset
on the next turn for both native and image-understanding failures. Removing
the deduplication reproduces two notices in both regression cases.
2026-10-28 04:09:35 +08:00
YHH 1ff581ef86 Merge pull request #14506 from SivanCola/fix/session-switch-sidebar-followup
Fix topic archive fencing for canonical sidebar rows / 修复规范会话行的话题归档屏障
2026-10-25 20:48:28 -07:00
zumac 88c3a736ba refactor: give remote state publication and reviewed keys one owner
Problem: The generic remote event bridge recovered a tab ID by parsing the
event name to stamp hydration revisions, reviewed-version detection matched a
substring of the source key, and the integrity-reuse comment claimed the SQLite
header detects checkpoints that WAL-mode commits need not record there.

Root cause: Revision stamping was attached to the transport rather than to the
state publisher, and the reviewed-version key format had no named parser.

Fix: Route all twelve state publications through publishRemoteTabState, which
stamps the revision before emitting; emitRemoteEvent no longer inspects state
payloads. Parse reviewed keys with historicalVersionSourceKey, which requires
both a source key and a version. Correct the header comment: file identity,
size and mtime remain the primary change signal.

Verification: The reviewed-key test failed before the parser existed. Full
reasonix/desktop package (717s), remote hydration/publication and source alias
race tests, internal/projectiondb, go vet, gofmt and repolint passed.
2026-10-25 20:22:25 -07:00
zumac ddd0f11cd0 fix: keep topic archive tombstones fencing canonical session rows
Problem: Archiving a conversation through the topic path left its sidebar row
visible. A row that carries a SessionRef was neither optimistically removed
after commit nor fenced from runtime overlays or stale pages until reload.

Root cause: Canonical identity isolation made projectSessionExcluded answer
only from the SessionRef identity. Topic archive commits and optimistic removal
key their tombstone by topicId, which the canonical branch no longer read.

Fix: Check the topicId tombstone before resolving identity. Topic tombstones
hold raw topic IDs, not identity aliases, so lifecycle fence isolation between
canonical sessions sharing a legacy alias is unchanged.

Verification: The new canonical topic-archive case failed before the fix and
passes afterward. test:session-switch-regression, 30 adjacent sidebar/archive
suites, both frontend typechecks, eslint and repolint passed.
2026-10-24 21:28:29 -07:00
YHH 53b94a785e Merge pull request #14389 from SivanCola/fix/session-switch-sidebar
Fix session switching stalls and duplicate sidebar rows / 修复会话切换卡顿与侧栏重复
2026-10-24 20:29:30 -07:00
SivanColaandsituerte37 ab89a3d94e fix: scope remote hydration revisions to the desktop service lifetime
Problem: A surviving renderer could reject ready notifications after the Go
desktop service restarted because restored remote tab IDs kept their identity
while the per-tab publication revision restarted from zero.

Root cause: The hydration owner retained the old service's high-water revision
and installed subscription across native service-generation changes.

Fix: Retire hydration ownership when the native service becomes unavailable or
its generation changes, then use the existing desktop:resync read to adopt the
replacement service. Fence late state-RPC replies and baseline completions;
preserve resident content and avoid cleanup RPCs to a replacement service.
Keep same-service duplicate readiness and recovery signals coalesced.

Verification: The real-hook restart regression failed before the fix and passes
afterward, including missed shutdown events and stale state RPCs. Owner tests,
Chromium local/remote regressions, frontend typechecks, remote/transcript suites,
production build and lint passed. Recovery adds 311 raw initial bytes; the
measured raw ceiling preserves the existing producer margin and all compressed,
CSS, locale, chunk and residency limits remain unchanged.

Co-authored-by: situerte37 <[email protected]>
2026-10-25 10:59:04 +08:00
SivanColaandsituerte37 cbd8487a0e fix: validate session identity at projection path construction
Problem: CodeQL flagged request-controlled session identity reaching the new
integrity-certificate file reads. Projection path builders also silently
reduced invalid identities to basenames instead of rejecting them locally.

Root cause: The constructors relied on SessionRef admission validation in their
callers. Taking a basename can alias another canonical session, and dot IDs can
leave the intended per-session cache directory if that precondition is missed.

Fix: Route history and search through one projection-path constructor that
checks locality and canonical identity before joining the host-owned root,
fixed cache directory and fixed index filename. SQLite and certificate reads
continue to use exactly that same path. Preserve all integrity reuse guards.

Verification: The new invalid-identity regression failed before the fix and
passed afterward. Related session/projection race tests and lint passed. Local
CodeQL go/path-injection analysis reproduced both reported integrity_reuse.go
alerts before the change and reports neither after it, without suppressions.

Co-authored-by: situerte37 <[email protected]>
2026-10-25 10:46:04 +08:00
SivanColaandsituerte37 7ee39c1d69 fix: isolate session identities and coalesce local and remote hydration
Problem: Same-workspace navigation could read a history baseline repeatedly,
while independent historical sessions sharing a source alias could acquire
duplicate sidebar keys, collapse during paging, or affect each other's archive
visibility. Small reads also repeated full integrity scans of large indexes.

Root cause: Navigation and passive ready/rebuilt events competed for hydration;
source aliases crossed canonical ownership boundaries; and each read entry point
opened disposable projections with a fresh full-database audit.

Fix: Give canonical navigation one hydration owner, version remote publication
barriers, and preserve distinct canonical identities across keys, pages, runtime
overlays and archive fences. Centralize history/search projection opens and reuse
successful integrity proofs only for unchanged files without outstanding WAL.

Verification: Root Go tests passed across 158 packages. All 12 Desktop CI test
partitions passed locally, including historical writer fixtures. Related race,
frontend typecheck/transcript/remote/lifecycle, Chromium regression, production
build, lint, generated-contract and Windows cross-build checks passed. The
terminal notification regression validates the additive publication revision.

Co-authored-by: situerte37 <[email protected]>
2026-10-25 01:28:53 +08:00
YHH 8cc804e687 Merge pull request #14083 from SivanCola/fix/composer-close-draft-errors
fix(desktop): preserve drafts across close and async input / 修复关闭窗口与异步输入的草稿保护
2026-10-24 08:20:16 -07:00
SivanCola 98622dad1e fix(desktop): present attachment warnings through shared error UI
Problem:
The frontend CI error-display contract rejected the attachment warning in
SessionInputRecovery because it rendered raw error text directly.

Root cause:
Separating attachment feedback from durable save failures introduced an
inline error label that bypassed the existing ErrorMessage presenter.

Fix:
Use ErrorMessage for the warning while retaining its independent dismissal
and non-blocking close behavior. Extend the close regression to verify the
summary, initially collapsed diagnostic details, explicit expansion, and
dismissal without blocking an otherwise blank window.

Verification:
The failing error-display contract and related presentation/close suites
passed (18 tests). Production and test typechecks, ESLint, repolint and the
Electron production bundle budget passed; no budget settings changed.
2026-10-24 22:33:30 +08:00
SivanCola 5159ce9c74 fix(desktop): keep purge receipt handling out of startup imports
Problem:
The PR's desktop-prepare build exceeded the initial payload ceiling by 70
bytes, blocking the dependent Desktop checks.

Root cause:
The eager bridge dynamically imported composer persistence for purge
receipts. Its generated preload dependencies grew the startup payload and
made every successful RPC callback async, even without any draft owner.

Fix:
Let the persistence module register purge receipt handlers before it can
create owners. Look up the handler at RPC completion so an owner loaded
while a purge is pending is still retired. Keep cold purges independent of
the persistence module and restore synchronous success callbacks.
Add a regression covering cold purges and owner registration during an RPC.

Verification:
- Reproduced the CI payload exactly: 2106642 B against a 2106572 B ceiling.
- Fixed production payload: 2106068 B, 574 B smaller and 504 B below the
  unchanged ceiling; all bundle gates passed with the same embedded SHA.
- 52 focused frontend tests, production/test typechecks, ESLint, repolint,
  and all five native macOS Electron close/restart scenarios passed.
2026-10-24 22:21:34 +08:00
SivanCola a2e2b38ba6 fix: keep image error copy within locale budget and satisfy lint
Problem: PR CI rejected the Traditional Chinese bundle and the historical
image replay regression's loop syntax, blocking downstream Desktop checks.

Root cause: the added error copy increased zh-TW gzip to 65,870 bytes against
a 65,843-byte budget; the pinned modernize linter requires integer ranges.

Fix: shorten the two error summaries while retaining the details control,
and use an integer range for the two repeated replay attempts. Keep all
bundle budgets and regression assertions unchanged.

Verification: Node 24.20.0 production bundle and all budgets pass, with
zh-TW gzip at 65,840 bytes. Pinned golangci-lint reports zero issues;
repolint, frontend test typecheck, error-presentation tests, focused
historical-image replay regression, docs-impact and diff checks pass.
2026-10-24 22:17:40 +08:00
SivanCola 48c5b29acb fix(images): keep text turns usable and preserve failure causes
Problem: historical image failures could block text-only follow-ups, and
reopened conversations displayed a generic error instead of the cause.

Root cause: replay preparation propagated optional image failures to the
entire turn; interrupted records and cleanup retained classification only.
Pre-Agent image failures also lacked a durable display record.

Fix: preserve text with explicit unavailable-image notes for historical
and tool images, while requiring current user attachments and honoring
parent cancellation. Persist bounded redacted causes through existing
recovery fields and optional notices, with localized image-error display.

Verification: root Go suite (158 tested packages), relevant Desktop tests,
focused Controller race regressions, frontend typecheck and three-locale
error tests, Chrome detail expansion, and v1.39.8/v1.39.9 replay/append/reopen
compatibility passed. Correct the heartbeat test to measure compaction
before transport cleanup; 30 repeated runs pass without changing timeouts.
2026-10-23 12:25:21 +08:00
YHH 7aa034224b Merge pull request #14094 from SivanCola/fix/provider-protocol-compat
fix(provider): scope HTTP/1.1 compatibility to later requests without replay
2026-10-21 06:29:01 -07:00
YHH 3a96b59bc1 Merge pull request #14108 from esengine/fix/attestation-verify-retry
fix(release): each provenance check retries on its own
2026-10-21 05:23:26 -07:00
esengine ab5e5074a5 fix(release): each provenance check retries on its own 2026-10-21 05:13:38 -07:00
YHH 136dad0094 Merge pull request #14106 from esengine/fix/attestation-verify-prime
fix(release): verify one payload alone before the parallel provenance checks
2026-10-21 05:03:51 -07:00
esengine 91ea60ade6 fix(release): explain the intentional single quotes the shell lint flags 2026-10-21 04:53:04 -07:00
esengine 3e03f73c6f fix(release): verify one payload alone before the parallel provenance checks 2026-10-21 04:47:24 -07:00
YHH 7461ea823f Merge pull request #14105 from esengine/fix/recover-orphan-signature
fix(release): recovery drops a Desktop signature whose payload never uploaded
2026-10-21 04:26:03 -07:00
esengine 132bcf98ab fix(release): recovery drops a Desktop signature whose payload never uploaded 2026-10-21 04:10:56 -07:00
zumac e5354ea072 Merge branch 'main-v2' of https://github.com/esengine/DeepSeek-Reasonix into fix/pr-14094-protocol-compat
# Conflicts:
#	desktop/frontend/scripts/check-bundle-budget.mjs
2026-10-21 03:54:08 -07:00
YHH f5282fe481 Merge pull request #14102 from esengine/fix/1x-bundle-budget-zh
fix(desktop): raise the zh and initial-raw bundle ceilings for the merged 1.x copy
2026-10-21 03:52:59 -07:00
zumac d558ac1f4e fix(desktop): ratchet the zh and raw initial budgets for transport kinds
The typed transport-failure copy from #14080 put main-v2 over two bundle
gates, failing desktop-prepare on every push and PR based on it. Under the
Node 24.20 zlib CI uses, the zh chunk measures 64826 B against a 64819 B
ceiling; once that passes, initial raw JavaScript and CSS measures 2107767 B
locally (about 2107953 B with the documented Linux producer difference)
against 2107596 B. zh-TW measures 65838 B and still fits.

Raise zh to 63.4 KiB and the raw initial gate to 2058.6 KiB, the smallest
one-decimal ceilings covering those measurements. Every other budget stays.
2026-10-21 03:28:04 -07:00
esengine 0af618a66b fix(desktop): raise the zh and initial-raw bundle ceilings for the merged 1.x copy 2026-10-21 03:27:45 -07:00
YHH 47d6f06ae1 Merge pull request #14081 from SivanCola/chore/provider-network-probe
chore(provider): add bounded network probe and diagnostic compatibility checks / 网络对照探针与兼容性检查
2026-10-21 03:04:17 -07:00
zumac 207a921f0d fix(provider): keep existing recovery on the compatibility route
A failed HTTP/1.1 compatibility request was wrapped as terminal, so an EOF,
reset or send timeout on that route lost the stale-connection/send-timeout
resend and the agent's stream recovery, and ClassifyRecovery reported it as a
"protocol" failure with an empty code. Any typed HTTP/2 code was also treated
as terminal, not just the three compatibility triggers, which changed how
REFUSED_STREAM, CANCEL and GOAWAY were classified.

- Drop protocolCompatibilityError: the wrapper returns transport errors as
  they are, so existing retry owners judge them by their real cause. A failed
  probe still disables the route; any owed resend uses normal negotiation.
- protocolFailureIsTerminal covers only INTERNAL_ERROR, PROTOCOL_ERROR and
  HTTP_1_1_REQUIRED; other HTTP/2 codes keep their pre-existing handling.
- INTERNAL_ERROR is also a generic upstream reset, so it now records a hint
  only when a request started after the first observation fails again.
  Concurrent streams reset by one event count once. PROTOCOL_ERROR and
  HTTP_1_1_REQUIRED still record a hint immediately.
2026-10-21 03:01:48 -07:00
SivanCola b61bc97dc0 Merge main-v2 transport diagnostics into protocol compatibility
Problem:
PR #14094 conflicts with the transport diagnostics merged by #14080.

Root cause:
Both branches extend request observations, send completion bookkeeping and
the provider diagnostics documentation at the same locations.

Fix:
Preserve the typed error, phase and send timing fields alongside scoped
protocol fallback evidence. Keep the terminal compatibility error marker
when resolving send guard cancellation, without adding another retry.
Retain both diagnostic and protocol compatibility documentation.

Verification:
Provider and adapter tests pass. Focused provider, agent and control race
tests pass, including no tool replay, no stacked resends and cold exports.
Add joint real-HTTP regression assertions for fallback EOF/send timeout
classification and timing. Desktop history failure tests pass.
Repolint and pinned golangci-lint v2.12.2 pass; diff checks are clean.
2026-10-21 17:42:36 +08:00
YHH e894514bb2 Merge pull request #14080 from SivanCola/feat/provider-transport-diagnostics
feat(provider): attribute transport failures with typed causes / 传输失败类型化诊断
2026-10-21 02:04:08 -07:00
SivanCola f0897cf797 fix(provider): scope automatic protocol compatibility to later requests
Problem: A provider path returning typed HTTP/2 protocol errors keeps failing
on subsequent requests, while replaying an ambiguous failed generation could
duplicate processing or tool execution.

Root cause: Built-in provider clients have no bounded per-path protocol hint,
and generic stream/reset recovery can treat protocol failures as retryable.

Fix: Remember allowlisted HTTP/2 failures by provider, adapter, endpoint and
resolved proxy route for ten minutes. Try HTTP/1.1 only on a subsequent new
request, limit unproven probes to one, and disable failed compatibility paths.
Keep protocol failures terminal across transport and agent recovery so no
resend allowance is stacked. Preserve sibling streams and record only safe
selection evidence in optional versioned diagnostics.

Verification: Provider and adapter suites pass, as do focused real TLS H1/H2,
request-count, tool execution, cancellation and concurrent recovery tests with
the race detector. Desktop diagnostic/provider tests, repository lint and
Go lint pass. No live provider call or native package validation is claimed.
2026-10-21 16:51:00 +08:00
YHH 07d6c66929 Merge pull request #14089 from SivanCola/feature/studio-discovery-1x
feat(desktop): introduce Studio to 1.x users / 为 1.x 用户提供 Studio 引导
2026-10-21 01:36:01 -07:00
YHH 990375ba71 Merge pull request #14074 from SivanCola/fix/provider-send-timeout
fix(provider): bound request sending and resend an unfinished upload once / 补齐请求发送阶段保护
2026-10-21 01:21:28 -07:00
SivanCola 6f95b0166b feat(desktop): introduce Studio to stable 1.x users
Add a one-time idle startup introduction and a permanent Settings > About entry. Respect setup, active work, overlays and user interaction, and persist the campaign independently of patch versions.

Open the official Studio download section on explicit action, explain shared-data limitations, and preserve the 1.x updater. Include three locales, keyboard focus handling, bilingual documentation and measured bundle allowances.

Verification: focused discovery, updater and preferences tests; frontend and test typechecks; production build and bundle checks; CSS syntax; repository lint; browser interaction and download checks.
2026-10-21 15:51:57 +08:00
SivanCola 2c81a74225 fix(desktop): preserve accepted composer input across send and close
Problem:
Accepted attachments and clipboard input could lose a race with submission.
An asynchronous cut could apply stale selection offsets to newer draft text
or finish after the window had already closed.

Root cause:
Pending-input tracking started after asynchronous target capture, omitted
menu reads and native fallback timers, and was absent at the durable
submission boundary. Cut resumed against current text without validating
the captured content and updated a local cache for background sessions.

Fix:
Track accepted input for its full lifetime, block submission while input
is pending, and retain close draining without counting submission itself
as input. Apply cuts through the authoritative draft owner only when the
captured text, invocations and pasted blocks still match. Preserve content
when clipboard writes fail. Add deterministic interleaving regressions
with independent per-scenario deadlines.

Verification:
- Focused frontend regression run: 52 tests passed, including the existing
  composer draft suite with 127 assertions and purge/save-failure protection.
- Frontend production build, production/test typechecks and repolint passed.
- Native Electron composer-close smoke: all five scenarios passed, covering
  empty windows and unsent text/reference recovery after restart on macOS.
2026-10-21 15:30:36 +08:00
zumac 9fafd7ce3a chore(provider): add bounded network probe and diagnostic compatibility checks
provider-network-probe sends exactly one synthetic request (SendOnce, no
redirects, no body replay, max_tokens 1) and prints safe observation fields,
for comparing network paths and protocols on an affected machine. The
compatibility script replays framed stores across v1.39.8/v1.39.9 in both
directions; the packaged smoke drives stop, history, live EOF and export.
2026-10-21 00:28:43 -07:00
zumac 1019b2811d feat(provider): attribute transport failures with typed causes
Transport failures now carry a whitelisted cause code (eof, reset, timeout,
dns, http2, request_send_timeout) and the phase it happened in, without
persisting error prose, URLs, headers or bodies. FailureDiagnostic gains the
matching kinds; request observations gain errorCode, errorPhase,
timeoutSource and send timing, carried by optional diagnostic/provider
schema 2 so older readers skip it. The desktop presents the new kinds in all
three locales for live and history errors.
2026-10-21 00:28:43 -07:00
YHH 9ee6ad0a81 Merge pull request #14072 from SivanCola/fix/tui-scroll-footer-14065
fix(cli): preserve footer and Unicode layout / 修复底栏重复、面板溢出与组合字符丢失
2026-10-21 00:15:47 -07:00
zumac 83a9b00566 fix(provider): bound request sending and resend an unfinished upload once
A provider upload could stall before either ResponseHeaderTimeout (which
starts only after the body is written) or the SSE idle watchdog took
ownership. Each attempt now has a 300-second send-stage budget, ended by a
successful WroteRequest and rearmed per redirect and Transport retry.

A send timeout means the body never finished uploading, so the provider
cannot have answered; it shares the existing single-resend allowance with a
stale idle pooled connection. SendOnce keeps one-request callers exact.

The per-attempt context is detached from the caller's cancellation tree and
released, not cancelled, when the response body finishes: cancelling a
finished HTTP/2 stream raced its teardown and made the next request see the
pooled connection as busy, which broke stale-connection detection.
2026-10-21 00:13:35 -07:00
SivanCola a9fc7d8a27 fix(desktop): preserve accepted input and retire purged drafts
Problem:
Accepted attachments and clipboard results could disappear during autosave
recovery, while failed drafts of permanently deleted sessions could prevent
an otherwise blank window from closing.

Root cause:
Draft publication shared the recovery admission lock, silently dropping
already accepted asynchronous work. Successful purge receipts did not revoke
cached composer ownership or release close barriers waiting on that owner.

Fix:
Allow accepted patches to join the retained local draft during recovery,
without clearing unreadable-store errors or bypassing submission locks.
Retire composer owners only after successful canonical purge receipts,
including committed items in partial bulk results. Fence late callbacks and
release in-flight close waits while preserving other unsaved draft failures.

Verification:
- 41 composer persistence, race, lifecycle and attachment regression tests.
- 6 archive, trash and historical recovery UI suites.
- 33 Electron lifecycle and startup-window tests.
- 3 backend composer lifecycle tests with the Go race detector.
- Frontend and test typechecks, production Electron frontend build, repolint.
- 5 native Electron close/restart smoke cases, including blank windows,
  immediate unsent text and durable external references.
2026-10-21 15:09:48 +08:00
SivanCola f2d59e6fa0 fix(desktop): drain late composer work and retry failed close saves
Problem:
Closing a window could miss late work on an earlier session, and another
close attempt could keep failing after draft storage had recovered.

Root cause:
The close barrier visited each session only once, native input admission
ignored the exit lock, and cached persistence errors prevented a fresh save.

Fix:
Block new edits during close while retaining accepted task completions.
Recheck all session owners until their work and saves have drained. Reconcile
cached failures once per explicit close attempt, preserving retained input
and keeping fresh storage failures as close vetoes. Never resend ambiguous
submissions during recovery.
Await each native smoke RPC poll and retain the matching saved snapshot so
the restart assertion cannot compare against stale pre-reference content.
Target the main renderer's native window and check its actual hidden state.
Bound inspector cleanup after native window destruction; only terminate the
isolated fixture group after logs confirm service shutdown, resource cleanup,
and debugger teardown.

Verification:
Six new deterministic regression scenarios and the related composer and
submission suites passed: 35 runner tests, including 256 Composer assertions.
The Electron-targeted frontend build, test TypeScript checks including the
new TSX suite, ESLint, and repository lint passed. The native macOS smoke
passed all five blank-window and draft close/restart checks. Windows native
behavior has not been exercised locally.
2026-10-21 14:48:58 +08:00
SivanCola 4e9ae7baba fix(cli): report pinned panel focus from selection state
Problem: Pinned panels could scroll to ordinary todo text that resembled
a selection marker, or lose focus when display wording changed.

Root cause: The viewport inferred selection from ANSI-stripped prefixes
and used the rendered row text as its focus identity.

Fix: Have panel builders record selected row boundaries and logical
indices, then return explicit terminal-row metadata after styling and
wrapping. Apply this to every pinned renderer, including native managers,
and restore the original elicitation Submit presentation.

Verification: Preserve existing scroll, footer and selection tests; add
misleading todo prefixes, wording-independent manual scroll, windowed
selection, Unicode wrapping and native-manager focus regressions. The
todo regression fails on the previous PR head for all three prefixes.
go test ./internal/cli -count=1, go build -p 2 ./..., make lint, and the
documentation-impact check pass.
2026-10-21 14:35:18 +08:00
SivanCola 732176487f fix(desktop): preserve drafts without blocking blank window close
Problem:
Closing a blank window could fail with unsupported: attachments-v2,
while real unsaved input must remain protected during shutdown.

Root cause:
Attachment operation failures shared the durable input error state,
and loading saved external references required a live attachment controller.
Close also checked dirty state before pending reads and recovery settled.

Fix:
Separate dismissible attachment feedback from persistence failures, defer
external reference activation to submission, and await input lifecycle
barriers before flushing. Keep genuine save failures as close vetoes.

Verification:
Add eight lifecycle regression cases and a native Electron close/restart
smoke covering blank windows, latest text, and saved external references.
Focused frontend tests, TypeScript checks, lint, Electron lifecycle tests,
builds, and repository lint passed. All five native smoke checks passed
on macOS. Windows native behavior has not been exercised locally.
2026-10-21 14:14:58 +08:00
SivanCola be1658c697 fix(cli): preserve terminal layout while scrolling and wrapping
Problem: Scrolling could duplicate the pinned footer, long panels could hide
status rows, and keycap or combining characters could disappear at line ends.

Root cause: The renderer's cursor model could disagree with the terminal's
Unicode width model, optional panels lacked a shared height budget, and the
upstream wrapping fast path could split ASCII-led grapheme clusters.

Fix: Update the official renderer, share a bounded scrollable panel layout
between painting and sizing, and wrap styled terminal text by complete
grapheme clusters. Keep keyboard selection and composer geometry in sync.
Document panel scrolling in the English and Chinese guides.

Verification: Final complete CLI tests, make lint, module verification and
Ghostty WASM replays pass. Root-module tests passed after the dependency
update. Before/after probes reproduce the original rendering failures.
Native Ghostty window acceptance remains unverified.

Fixes #14065
2026-10-21 14:03:40 +08:00
YHH 195fc08132 Merge pull request #14058 from esengine/docs/release-1.39.10-notes
docs(release): prepare v1.39.10 notes
2026-10-20 20:33:44 -07:00
esengine baa8ff0f56 docs(release): prepare v1.39.10 notes 2026-10-20 20:31:15 -07:00
YHH 92855769ae Merge pull request #14051 from esengine/fix/go-1.26.10-x509-1x
build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809)
2026-10-20 20:31:03 -07:00
esengine aeadc04396 build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809) 2026-10-20 20:02:17 -07:00
YHH 0a019e294e Merge pull request #14029 from SivanCola/fix/opencode-go-inference-v41
Fix OpenCode Go inference and DeepSeek V4.1 compatibility / 修复 OpenCode Go 接口与 DeepSeek V4.1 适配
2026-10-20 11:25:38 -07:00
SivanCola 8e58a1fd3d refactor: simplify OpenCode Go migration eligibility check
Problem: the migration model scan triggered the required modernize lint rule.

Root cause: a manual loop only tested whether any model has the DeepSeek Go contract.

Fix: use slices.ContainsFunc while preserving the one-time model and pricing updates.

Verification: make lint passes with zero golangci-lint issues and clean repolint; the full config package passes after the change.
2026-10-21 01:52:46 +08:00
SivanCola df6aae6f07 test: isolate V4.1 tool replay acceptance through upstream outages
Problem: independent smoke cases could not complete the dependent V4.1 tool-history probe during intermittent Go upstream 503 failures.

Add a focused three-request conversation probe and an optional synthetic session identity for routing controls. Preserve pending messages across at most five explicitly logged 503-only probe retries, spaced ten seconds apart. Production request behavior is unchanged.

Verification: the live chain completed tool invocation, result replay and multi-turn recall with stable session headers and retained reasoning. It used five HTTP requests, including two recorded 503 failures, then returned 200 at each stage. Usage was 1137 input tokens, 62 output tokens and 640 cached input tokens. Tagged compilation, focused boot checks and go vet passed; temporary state and binary were removed.
2026-10-21 01:48:31 +08:00
SivanCola 34289879ad test: add bounded OpenCode Go V4.1 live acceptance
Exercise the production boot factory on new and legacy Go routes, default and explicit effort modes, stable per-conversation identity, tool-result replay, multi-turn history, shared discovery and an Anthropic control.

Limit inference calls to eight, cap each response at 768 output tokens and 60 seconds, use disposable Reasonix state, and log only sanitized transport and usage summaries. Independent cases retain upstream failures without automatic retry.

Live verification found two successful V4.1 responses followed by six upstream HTTP 503 Endpoint is unavailable responses across both route generations. The same key succeeded on Qwen and shared discovery. Full V4.1 tool replay remains blocked by upstream availability; no live credential is persisted. Tagged compilation and focused deterministic boot checks passed.
2026-10-21 01:41:47 +08:00
SivanCola fff1350970 fix: align OpenCode Go routes and DeepSeek V4.1 contracts
Problem: OpenCode Go V4.1 Flash requests could bypass DeepSeek thinking and history replay, while newly documented inference URLs missed Go-specific transport and discovery handling.

Root cause: exact endpoint and model catalogs recognized only the legacy route generation and older DeepSeek IDs.

Fix: recognize both endpoint generations, add the V4.1 contract, adopt documented inference presets and shared model discovery, and atomically migrate standard saved connections with schema 13 while preserving selections and custom configuration.

Verification: root Go suite, affected Desktop/provider tests, schema-12 reader/writer round trip, migration interruption and Windows encoding fixtures, frontend catalog/protocol tests, TypeScript and go vet passed. Desktop full-suite execution exhausted its 10-minute package budget; the test active at timeout passed individually in 0.49 seconds. No live Go credential was available.
2026-10-21 01:34:11 +08:00
YHH 5207eaeff6 Merge pull request #14020 from esengine/notes/1.39.9-content-index
docs(release-notes): add the content index self-heal to 1.39.9
2026-10-20 06:20:42 -07:00
YHH d6c0cda861 Merge pull request #14019 from esengine/fix/content-index-self-heal
fix(sessioncontent): rebuild a lost integrity index from its object
2026-10-20 06:10:33 -07:00
esengine cadcfd3c44 fix(sessioncontent): rebuild a lost integrity index from its object 2026-10-20 05:54:11 -07:00
esengine a0aab3373e docs(release-notes): add the content index self-heal to 1.39.9 2026-10-20 05:24:49 -07:00
YHH 8b7eaac0f0 Merge pull request #14014 from esengine/fix/shellrun-mergepath-test-windows
test(shellrun): build the mergePath fixture with the platform list separator
2026-10-20 04:02:41 -07:00
esengine 301fdea4a8 test(shellrun): build the mergePath fixture with the platform list separator 2026-10-20 03:46:20 -07:00
YHH 39c6204bdf Merge pull request #14008 from esengine/a9/release-notes-1.39.9
docs(release): prepare v1.39.9 notes
2026-10-20 03:23:51 -07:00
esengine 81e6b67426 docs(release): prepare v1.39.9 notes 2026-10-20 01:59:56 -07:00
YHH 302535d501 Merge pull request #14005 from SivanCola/codex/settings-model-capability-consistency
fix(desktop): align model settings validation / 统一模型设置校验并保留默认值
2026-10-20 01:34:13 -07:00
YHH bbb42d3c1c Merge pull request #13543 from SivanCola/fix/native-file-streaming-main-v2
fix(desktop): reference native files and stream attachment uploads / 大文件路径引用与流式上传
2026-10-20 00:40:26 -07:00
SivanCola 2f34afada8 fix(desktop): align model settings capabilities and preserve defaults
Problem:
Settings can reject image-capable models, display incorrect reasoning choices
or planner state, and change connection defaults during unrelated edits.

Root cause:
Vision selection used the legacy capability check. Reasoning choices read
persisted declarations instead of resolved model metadata. The planner picker
collapsed explicit default-model assignments, and provider saves reconstructed
defaults from model order and explicit effort lists.

Fix:
Use the shared vision resolver and resolved reasoning options, retain explicit
planner assignments until cleared, and preserve connection defaults on save.
Retain declared reasoning choices for older hosts without resolved metadata.

Verification:
Focused Desktop Go and config tests; new settings component regressions;
provider editor, settings refresh, and search assignment suites; frontend and
test typechecks; focused ESLint; repolint; browser component interactions.
2026-10-20 15:34:02 +08:00
YHH 12bdc4fb8f Merge pull request #13966 from SivanCola/fix/reasoning-only-delivery
fix(agent): recover reasoning-only turns and report failure / 修复仅思考空转与失败提示
2026-10-20 00:15:50 -07:00
SivanCola 76d4d51145 test(control): use http.StatusOK in no-deliverable fixture
Satisfies the usestdlibvars lint flagged by lint-code CI.
2026-10-20 13:00:18 +08:00
SivanCola 156c1f9c1a fix(agent): let budgets win recovery exhaustion and pin cancel semantics
Problem: A task budget spent by the recovery round surfaced as the
non-resumable no_deliverable_output failure, response diagnostics attributed
recoveryAttempts to every later round in the Run, and the cancel-after-commit
outcome had no test or documented contract.

Root cause: The budget check ran after the exhaustion check, and the response
summary read the Run-wide no-answer counter instead of whether that response
answered a recovery prompt.

Fix: Check the task budget before counting a no-answer stop so a spent budget
returns the resumable pause. Track answeringRecovery in terminalProtocolState
so only the reply to a recovery prompt carries a non-zero count. Keep
cancellation winning until Run returns even after a committed answer, matching
DeepSeek Harness, and document it with the existing cancelled-turn replay
policy.

Verification: New regressions fail on the previous code and pass now
(TestNoDeliverableBudgetSpentDuringRecoveryPauses, recovery attribution
[0 1 0], TestCancelAfterCommittedAnswerEndsRunCancelled). go vet, gofmt,
repolint, and race tests for agent, control, guardian, and transcript passed.
2026-10-20 12:54:15 +08:00
SivanCola 8d2df779c0 fix(agent): reject reasoning-only turns without a delivered answer
Problem: Ordinary chat turns could complete with only reasoning, leaving no
answer or tool action and no durable failure notice.

Root cause: Empty-stream validation accepted reasoning, while visible-answer
recovery only covered explicit callers or turns that had already used tools.

Fix: Bound no-answer recovery to one continuation across the entire run, retain
completed tool results, and persist a typed failure when recovery is exhausted.
Unify terminal diagnostics, add optional content-free response summaries, and
share structured JSON redaction between live and cold exports.

Verification: Root Go suite, focused Agent/Controller/Guardian race tests,
Desktop export regressions, go vet, repolint, frontend error tests/typecheck/lint,
and wide/narrow browser error-presentation checks passed.
2026-10-20 11:42:37 +08:00
YHH b0efcb3b93 Merge pull request #13927 from PeterHepta/codex/fix-login-shell-env
fix: load login shell PATH for user commands
2026-10-19 04:12:56 -07:00
PeterHepta 9fe9c00907 fix: load login shell PATH for user commands 2026-10-19 18:55:42 +08:00
YHH d178d0a76e Merge pull request #13805 from esengine/dependabot/go_modules/desktop/main-v2/go-1x-d0657610f5
chore(deps): bump github.com/godbus/dbus/v5 from 5.2.2 to 5.2.4 in /desktop in the go-1x group
2026-10-18 06:00:10 -07:00
dependabot[bot] b69a587842 chore(deps): bump github.com/godbus/dbus/v5
Bumps the go-1x group in /desktop with 1 update: [github.com/godbus/dbus/v5](https://github.com/godbus/dbus).


Updates `github.com/godbus/dbus/v5` from 5.2.2 to 5.2.4
- [Release notes](https://github.com/godbus/dbus/releases)
- [Commits](https://github.com/godbus/dbus/compare/v5.2.2...v5.2.4)

---
updated-dependencies:
- dependency-name: github.com/godbus/dbus/v5
  dependency-version: 5.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:57:18 +00:00
SivanCola 1b8c281bc7 test(boot): classify request-owned attachment policy
Problem: Ubuntu, race and Windows boot CI reject the newly added
Attachments config as unclassified.

Root cause: the exhaustive runtime-config ownership inventory was not
updated alongside the user-only upload policy. Admission reloads that
policy per request; it is not an assembled controller dependency.

Fix: classify the container and MaxStreamBytes as host-owned exclusions,
retain exhaustive nested-field checks, and cover future attachment fields
with the mutation guard. Assert that custom, unlimited and default policies
leave the controller reuse fingerprint unchanged.

Verification: reproduced the original classification failure locally;
runtime-config regressions, full boot race suite, native Windows 11 ARM64
boot suite, pinned lint and repolint passed. No tests or CI guards disabled.
2026-10-18 09:59:12 +08:00
SivanCola 6bd9fa74c9 fix(attachments): bound streamed bytes with user-only policy
Problem: a pathless upload can fill workspace disk, and a typed size refusal
is hidden behind generic draft/error presentation in the composer.

Root cause: the stream owner has no byte ceiling; project config must not be
allowed to choose that safety policy or mutate a held user pointer value.

Fix: default to a named 1 GiB limit, count actual bytes with a bounded excess
probe, and retain rooted cleanup. Add a user-only optional ceiling (zero means
unlimited), strict user reads and round-trip persistence. Return typed limit
and source facts, keep retired-target precedence, and show a localized explicit
summary for the known refusal without reclassifying it as a generic exception.

Verification: fail-first limit/config regressions, config and streaming tests,
root/Desktop race tests, native Windows ARM64 upload/cleanup cases, frontend
and toast regressions, production build, real Electron large-file/limit flow,
pinned lint and repolint. No workflow guards or test timeouts are weakened.
2026-10-17 23:48:37 +08:00
SivanCola af35e5408f merge: include the reviewed history fix from main-v2
Problem: the attachment PR's Desktop and Windows checks fail on the base
branch's legacy recovery identity mismatch.

Root cause: the separately reviewed history fix in PR #13594 was not yet part
of main-v2 when the attachment candidate was tested.

Fix: merge official main-v2 after #13594 landed as 0a2d2a377. Keep the PR diff
limited to attachments while inheriting the base's history and lifecycle fix.

Verification: previously failing covered/unmodified recovery tests, canonical
inactive history and catalog lifetime regressions pass with race detection.
Attachment, external-reference, rooted-storage and legacy-identity race tests
also pass. Repository lint and diff checks are clean; no tests are skipped.
2026-10-17 22:59:59 +08:00
YHH 0a2d2a3776 Merge pull request #13594 from SivanCola/codex/history-catalog-recovery-main-v2
Fix legacy recovery identity and catalog reconciliation lifetime / 修复历史恢复身份与目录校验生命周期
2026-10-17 07:58:30 -07:00
SivanCola e4be215d14 revert: move history and catalog repair to its own main-v2 PR
Problem: the attachment PR also contains an independently reviewable legacy
history identity and catalog lifecycle repair, blocking scope approval.

Root cause: commit fe060ae88 combined the requested baseline CI follow-up with
the attachment transport fix instead of keeping a separate review boundary.

Fix: revert fe060ae88 with an ordinary commit. Preserve its implementation
and regressions in independent main-v2 PR #13594, including explicit-ID
compatibility coverage. Do not rewrite the attachment branch history.

Verification: the resulting tree equals e045489e4 exactly. The independent
history branch passes focused root/Desktop race tests, native Windows
history and lifecycle repetitions, pinned lint and repository lint. Existing
main-v2 history failures remain tracked in #13594 until that fix reaches base.

This reverts commit fe060ae88f.
2026-10-17 17:34:09 +08:00
SivanCola 21e3ceb01e test(sessioncatalog): preserve explicit recovery-prefixed topic IDs
Problem: splitting the history repair needs direct compatibility evidence for
topic IDs already persisted with the old recovery-prefixed spelling.

Root cause: synthetic cache IDs and explicit authoritative IDs can share a
prefix, so prefix matching alone cannot define a safe migration boundary.

Fix: cover explicit root, recovered-member and rootless authority, asserting
that projection preserves both identity and title in each case.

Verification: the focused legacy identity and compatibility race tests pass
three repetitions, including certified-cache reopen and parent deletion.
2026-10-17 17:31:37 +08:00
SivanCola 991e7dbebd fix(desktop): preserve recovery identity and retire catalog reconciliation
Problem: legacy recovery rows change topic identity after content proof or
parent deletion. Catalog rebuild can leave an explicit reconcile waiting on
paused discovery, and the Windows canonical-open test races a five-second
filesystem-poll deadline.

Root cause: metadata and content projections choose different fallback IDs;
reconcile jobs borrow the application lifetime and can attach to another
catalog generation; readiness assertions assume a particular disk speed.

Fix: anchor legacy groups to the existing legacy identity while preserving
explicit topics and authoritative files. Bind reconcile jobs to the captured
catalog and cancellation owner, detach them atomically on stop, release them
on invalidation, and fence old-job cleanup by pointer identity. Join the
existing history preparation before asserting readable history under a gated
runtime, and fence metadata/content-proof phases in recovery tests.

Verification: full session-catalog race suite, deterministic pre-fix failures
and focused Desktop race regressions, native Windows C group and repeated
canonical/recovery/lifecycle tests, pinned root/Desktop/Windows lint and
repolint. Complete Desktop inventory and historical version/scope coverage
are recorded in the PR validation.
2026-10-17 17:30:31 +08:00
SivanCola fe060ae88f fix(desktop): preserve recovery identity and retire catalog reconciliation
Problem: legacy recovery rows change topic identity after content proof or
parent deletion. Catalog rebuild can leave an explicit reconcile waiting on
paused discovery, and the Windows canonical-open test races a five-second
filesystem-poll deadline.

Root cause: metadata and content projections choose different fallback IDs;
reconcile jobs borrow the application lifetime and can attach to another
catalog generation; readiness assertions assume a particular disk speed.

Fix: anchor legacy groups to the existing legacy identity while preserving
explicit topics and authoritative files. Bind reconcile jobs to the captured
catalog and cancellation owner, detach them atomically on stop, release them
on invalidation, and fence old-job cleanup by pointer identity. Join the
existing history preparation before asserting readable history under a gated
runtime, and fence metadata/content-proof phases in recovery tests.

Verification: full session-catalog race suite, deterministic pre-fix failures
and focused Desktop race regressions, native Windows C group and repeated
canonical/recovery/lifecycle tests, pinned root/Desktop/Windows lint and
repolint. Complete Desktop inventory and historical version/scope coverage
are recorded in the PR validation.
2026-10-17 17:23:58 +08:00
SivanCola e045489e46 fix(attachments): contain file creation and cleanup with directory handles
Problem: CodeQL reports uncontrolled attachment paths in creation, interrupted
uploads and stale desktop-owner cleanup. A deterministic directory replacement
can make cleanup delete an external same-name file and leave the owned partial.

Root cause: validation and later absolute-path operations resolve the attachment
directory independently, so they do not retain one filesystem owner during I/O.

Fix: anchor creation to the workspace with os.Root, retain the attachment
directory handle through byte/stream write cleanup, and use rooted removal for
stale desktop writes and failed image previews. Preserve missing workspace
creation, extension validation, exclusive file creation and existing limits.

Verification: controller suite, attachment desktop regressions, deterministic
read-error/cancellation races, focused race detector, vet, pinned linter and
repolint passed. Native Windows 11 ARM64 controller/desktop regressions passed,
including junction escape rejection, protected cleanup and 80 MiB streaming.
The independent recovery catalog failure also reproduces with the PR's Go
changes removed through a base-source overlay; it is not changed here.
2026-10-17 13:08:06 +08:00
SivanCola 18d2427316 test(desktop): verify native Windows file attachment transport
Problem: Review found no native Windows evidence for absolute file tokens
reaching the tool-side read-root matcher, and the streaming description could
be mistaken for a total disk-byte budget.

Root cause: The previous regression exercised context parsing without wiring
the actual PathResolver and read_file tool, and the live harness had no
explicit Windows Electron/service override.

Fix: Cover native and drive-shaped tokens through registration and read_file,
including spaces and rejection of parent/sibling grants. Support explicit
runtime/service paths and the Windows service executable suffix in the live
regression. Clarify in both host protocol docs that streaming bounds memory,
while total file size remains subject to available storage.

Verification: Native Windows 11 ARM64 controller/desktop attachment suites and
real Electron 44.2.0 passed: 120 MiB picker, paste and drop references plus an
exact 80 MiB pathless upload; renderer and service stayed responsive. Focused
macOS tests, repository lint, script syntax and whitespace checks passed.
2026-10-17 11:42:28 +08:00
SivanCola 0e04f484df test(desktop): share ordinary file upload fixtures
Problem: Updated attachment regressions exceed the existing per-file test
budgets in the composer Goal and session-draft suites.

Root cause: Both suites inline the same raw-file fetch setup, and the Goal
suite also owns a paste-event builder that belongs with the upload fixture.

Fix: Extract shared raw upload assertions and file-paste dispatch while
preserving the suites' assertion counters, pending-upload ordering, failure
sanitization and session-ownership checks. Do not raise any lint baseline.

Verification: Composer Goal and session-draft suites, frontend test typecheck,
repository lint and whitespace checks pass.
2026-10-17 11:13:41 +08:00
SivanCola 8c5efcdada fix(desktop): reference native files and stream attachment uploads
Problem: Attaching a large ordinary file can terminate the desktop service.

Root cause: The composer reads and Base64-encodes the entire file before
sending it through JSON-RPC. The expanded request can exceed the 64 MiB
transport frame limit before the legacy attachment size guard runs.

Fix: Prefer native paths and exact external-file read references. Send
pathless files through an authenticated raw-body upload with bounded disk
writes, captured workspace ownership, and cancellation cleanup. Reject
oversized outbound RPC frames locally without closing the connection.
Preserve image admission, legacy Base64 limits, and persisted field formats.

Verification: Desktop Go suite passed with a 20-minute local timeout;
control and builtin tests, attachment race tests, 47 related frontend
suites, typechecks, lint, production builds, and Windows service cross-build
passed. Real macOS Electron exercised native picker, paste and drop with
120 MiB files and an exact 80 MiB pathless upload. Windows native runtime
verification remains outstanding.
2026-10-17 11:08:58 +08:00
YHH 99c1626d01 Merge pull request #13444 from esengine/fix/13439-ignore-v4-imports
fix(session): ignore Studio's v4- imports in session scans and maintenance
2026-10-16 05:06:20 -07:00
esengine 3f85cdad07 fix(session): ignore Studio's v4- imports in session scans and maintenance 2026-10-16 04:47:11 -07:00
YHH 3768de2569 Merge pull request #12951 from xxoingr/chore/main-v2-go1269-xnet060
chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0
2026-10-13 09:37:05 -07:00
esengine 8ef15184db chore(lint): keep recognising x/net's deprecated GoAwayError
x/net v0.60.0 marks http2.GoAwayError deprecated while its Transport still
returns it, so the transport classifier must keep matching it. Scope the
SA1019 exclusion to that file.
2026-10-13 08:49:14 -07:00
xxoingr 1b9afa89c7 chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0
govulncheck on main-v2 reports ten vulnerabilities the code reaches, in
the go1.26.6 standard library (net/http) and golang.org/x/net v0.58.0,
fixed in go1.26.9 and x/net v0.60.0. studio closed the same findings in
c8622f7a8; this is that change for main-v2, in both modules.

x/net v0.60.0 raises x/crypto to v0.57.0, x/text to v0.42.0, x/term to
v0.46.0 and x/sync to v0.23.0, the versions studio uses; go mod tidy
changes nothing else.
2026-10-13 20:47:56 +08:00
YHH afbc1c0a07 Merge pull request #12822 from esengine/fix/metadata-catalog-recovery-lineage
fix(desktop): prove recovery lineage after discovery so copies stop listing as sessions
2026-10-12 07:45:33 -07:00
yhh 8965290c35 fix(desktop): prove recovery lineage after discovery so copies stop listing as sessions
The desktop opens the session catalog metadata-only, which never reads
content, so a freshly created catalog file lists every conflict-recovery
copy as an ordinary conversation. Nothing in that mode ever proved which
copies a parent covers. After discovery completes the desktop now calls
ProveRecoveryLineage, which runs the existing full reconcile only for
directories that hold recovery copies, under the maintenance budget.
Metadata discovery itself still reads no content.
2026-10-12 06:30:46 -07:00
YHH bddb48202a Merge pull request #12526 from XTLine/fix/responses-incomplete-tool-stream
fix(responses): reject incomplete tool-stream completions / 修复工具调用未完成时静默结束
2026-10-09 20:17:56 -07:00
YHH 97576fd387 Merge pull request #12502 from XTLine/fix/goal-work-duration
fix(goal): restore cumulative active work duration / 恢复目标累计工作时长
2026-10-09 19:52:34 -07:00
XTLine 1ba983bdda fix(responses): preserve terminal error identity and verify parallel calls 2026-10-10 10:51:30 +08:00
YHH d6d3326140 Merge pull request #12535 from esengine/fix/v1-canonical-source-key
fix(desktop): settle duplicate source receipts on the one live session
2026-10-09 19:45:49 -07:00
yhh cdbbff8a41 test(desktop): cover retired receipts under duplicate-source settlement 2026-10-09 19:28:38 -07:00
yhh 505a3d7759 fix(desktop): settle duplicate source receipts on the one live session
One legacy source registered under several keys (path spellings or heads)
and held by two sessions made every lookup answer a state conflict, so the
topic's history never loaded. Receipts that agree on workspace and content
now resolve to the single active owner; retired copies keep their receipts
and files, and ambiguity with zero or several live owners still conflicts.
2026-10-09 19:02:39 -07:00
XTLine 1ff464b855 fix(responses): reject unsafe tool-stream completions 2026-10-10 09:20:54 +08:00
YHH c766bf7174 Merge pull request #12501 from XTLine/fix/compaction-session-state
fix(desktop): release compaction admission and apply live context settings / 修复压缩后的发送阻塞与实时设置更新
2026-10-09 18:14:11 -07:00
XTLine 2ff95b934e test(boot): guard config fields and canonical external paths 2026-10-10 08:40:39 +08:00
YHH b3e93df5e9 Merge pull request #12503 from XTLine/fix/history-recovery-status
fix(history): settle recovery status after successful import retry / 修复历史导入重试成功后的恢复状态残留
2026-10-09 09:27:21 -07:00
XTLine ee4214da3e fix(boot): apply live compaction settings without replacing sessions 2026-10-10 00:08:49 +08:00
XTLine 7b46a724b5 test(history): cover settlement failure after receipt completion 2026-10-09 23:52:53 +08:00
XTLine b4ecf529e3 fix(goal): isolate observational work duration 2026-10-09 22:36:36 +08:00
XTLine d483ab15a2 fix(desktop): regenerate goal work duration contract 2026-10-09 22:36:20 +08:00
XTLine 56a46b6c99 fix(history): settle reviewed and archived recovery receipts 2026-10-09 22:33:24 +08:00
XTLine 8d8579bb95 fix(control): preserve external folder access across runtime rebuilds 2026-10-09 22:28:12 +08:00
XTLine cac2d3b6fd fix(history): settle recovery status after successful import retry 2026-10-09 21:26:16 +08:00
XTLine e4c8a27b73 fix(goal): restore cumulative active work duration 2026-10-09 21:26:04 +08:00
XTLine 468efe6d29 fix(desktop): release compaction admission and apply live context settings 2026-10-09 21:25:54 +08:00
YHH 18a8a95d1f Merge pull request #11513 from XTLine/fix/remote-session-badges
fix(remote): align session read badges and prevent false connection warnings / 统一远端会话已读徽标并修复连接状态误报
2026-10-08 22:26:43 -07:00
YHH eca6834dad Merge pull request #12418 from esengine/fix/attachment-oversize-before-read
fix(attachments): refuse oversized files before reading them into memory
2026-10-08 04:55:41 -07:00
yhh 694b096ffc fix(attachments): refuse oversized files before reading them into memory
The composer's file picker, paste and pathless drops hashed and base64-encoded the whole File before any size check, and the kernel decoded the whole data URL before comparing against the 25 MB cap. A large archive therefore exhausted memory instead of being refused.
2026-10-08 04:39:29 -07:00
YHH 4e7831ba12 Merge pull request #12307 from esengine/docs/release-1.39.8-notes-refresh
docs(release): refresh v1.39.8 stable notes
2026-10-07 07:26:49 -07:00
YHH 4024bc66dd Merge pull request #12302 from esengine/fix/image-gate-blocks-only-declared-text-only
fix(images): block image input only for models declared text-only
2026-10-07 05:32:07 -07:00
yhh e5b701091d docs(release): refresh v1.39.8 notes with fixes merged since the draft 2026-10-07 04:06:04 -07:00
yhh 92104fdf8a fix(images): block image input only for models declared text-only
An undeclared model was treated as unable to read images, so attaching one
failed with no image understanding model configured. Images left in a tool
result or in older history made every later request fail as a whole, even a
plain-text one.

Undeclared models now pass images to the provider unless a fallback image
model is configured; a vision_models list that omits the model declares it
text-only; tool and historical images that no model can read become a
host-written note instead of failing the request.
2026-10-07 03:46:24 -07:00
XTLine c204fe1a83 Merge branch 'main-v2' into fix/remote-session-badges 2026-10-05 16:47:26 +08:00
YHH 60204c3030 Merge pull request #12015 from esengine/fix/desktop-release-not-repo-latest
fix(release): desktop releases no longer claim the repository-wide latest
2026-10-04 20:56:09 -07:00
yhh b83ad671b4 fix(release): desktop releases no longer claim the repository-wide latest
Cause: stable desktop-v* releases were created with --latest, and the 1.x
CLI release smoke check fetched releases/latest/download/latest.json. Studio
is about to become the repository's GitHub latest; with this script still
claiming it, each 1.x desktop patch would take the badge back, and the smoke
check would read whichever release holds it.

Blast radius: GitHub release metadata for new desktop-v* stable releases
(latest flag only; prerelease and manual-exception paths already used
--latest=false) and one step of release.yml for stable v* tags. The updater
reads R2 and the release gateway, which do not use GitHub's latest. Comments
in release-desktop.yml, desktop/README.md and desktop/updater.go no longer
say desktop releases own the badge.

Neighbouring behaviours tested: scripts/release-workflows.test.sh (new
assertions pin --latest=false on the stable path and require the new CLI
asset verification step; both fail without the change); actionlint on
release.yml and release-desktop.yml; shellcheck on the publisher. The
verification step itself cannot run locally and was not exercised.

Why this layer: the flag is set in the one publisher script every desktop
release goes through. The replaced smoke check proved a public GitHub path
that is meant to go away; the new step checks what the CLI release serves
(asset set, sizes and digests against SHA256SUMS, uploaded latest.json
byte-identical) through the existing publication decider, failing closed.
2026-10-04 20:26:48 -07:00
YHH 3b4319cf9a Merge pull request #11783 from aron-intframe/fix/10456-derived-record-identity
fix(transcript): keep derived history record identities unique
2026-10-04 10:01:29 -07:00
YHH da81a9e600 Merge pull request #11583 from SilenceLurker/codex/qq-approval-delivery
fix(bot/qq): restore approval keyboard delivery and text fallback
2026-10-02 21:53:16 -07:00
INTFRAME a7c634dc63 fix(transcript): keep derived history record identities unique
Interrupted-attempt records are persisted without a message id, so
stampHistoryRows derived "m::notice:0" for every one of them and
NewProjection rejected the whole baseline, leaving the session
unopenable across restarts. Disambiguate the derived address the same
way repairCheckpointRecordIdentities already does. Canonical duplicates
are untouched and still rejected.

Refs #10456
2026-10-03 02:58:31 +07:00
YHH 8166f35a08 Merge pull request #11746 from esengine/docs/release-1.39.8-notes
docs(release): prepare v1.39.8 notes
2026-10-02 07:03:19 -07:00
esengine c55502f890 docs(release): prepare v1.39.8 notes 2026-10-02 06:58:10 -07:00
YHH 428877db31 Merge pull request #11717 from esengine/fix/11581-lineage-archive
fix(desktop): archive a whole recovery lineage in one action
2026-10-02 06:56:20 -07:00
esengine 756050ddf9 fix(desktop): archive a whole recovery lineage in one action
Recovery snapshots written by older builds chain each ParentID to the previous
snapshot, so matching on an equal ParentID retired only the few snapshots that
shared a parent per action. Follow the ParentID chain to its root, archive each
snapshot under its own runtime lock hold, and report an unfinished lineage as a
committed archived_partial outcome so a retry finishes it.

Refs #11581
2026-10-02 06:24:51 -07:00
YHH 3dae084f94 Merge pull request #11722 from esengine/fix/11693-catalog-reconcile-loop
fix(desktop): stop non-session churn from rescanning catalog roots
2026-10-02 06:01:42 -07:00
esengine da58ed7454 fix(desktop): stop non-session churn from rescanning catalog roots
Every filesystem event in a watched session directory that was not a
transcript or its .meta sidecar dirtied the whole root, and in the
advisory (metadata-only) catalog every admitted root runs a full scan
that always starts a new scan generation. Lock files, temp files of
atomic writes, sidecars other writers keep beside transcripts (2.x
.wire/.execution logs, .context.json), and subdirectory timestamps all
qualified, so a directory whose sessions never changed was rescanned
on every 250 ms batch for as long as anything touched it (#11693:
ready<->scanning every ~3 s, scan_generation +1 per sample, WAL growth).

- Events are admitted only for what a metadata scan reads: transcripts,
  their branch sidecar and their event log go to the exact-path index;
  removal of a transcript or a root event still reconciles the root;
  everything else in the directory is ignored.
- Full-root admissions are paced per root with exponential backoff
  (1 s doubling to 60 s, reset after two quiet minutes), so any trigger
  that never settles converges; a failing journal write backs off too
  instead of retrying every batch. A removed or renamed transcript,
  sidecar or event log expedites its root past the backoff without
  growing it; pacing state is dropped with the root.
- User-initiated archive, trash-topic and remove-topic paths poll the runtime mutation locks for up to
  1.5 s instead of a single TryLock that loses to every brief background
  holder (each discovery completion requests legacy receipt
  reconciliation, which can take the same locks). Busy refusals now log
  which holder refused them; the user-facing code is unchanged.

Refs #11693
2026-10-02 05:34:38 -07:00
YHH 39c861412a Merge pull request #11690 from esengine/fix/11338-subagent-rows
fix(desktop): keep sub-agent tool rows inside their parent card
2026-10-02 02:00:21 -07:00
esengine 406b761ff0 fix(desktop): nest sub-agent tool calls under their parent in record-projected transcripts
convertRecord built tool items from assistant records without copying
the call's parentId, so every sub-agent call (task and fleet workers)
rendered as a flat sibling row in the main list on the transcript v2
path, live and after reopening a session. Also pin that the runtime tags
a background task's tool events with the parent call id.
2026-10-02 01:34:11 -07:00
YHH dbaaf72c01 Merge pull request #11666 from Harbor404/fix/main-v2-11617-commandcode-400-retry
fix(responses): retry Command Code transient 400 once
2026-10-02 00:17:15 -07:00
Harbor404 f3cc04465f fix(responses): keep Command Code retry within lint limits 2026-10-02 14:45:13 +08:00
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
Harbor404 6c32116500 fix(responses): retry Command Code transient 400 once 2026-10-02 13:27:54 +08: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
SilenceLurker 38782678d4 chore: merge latest main-v2 into QQ approval fix 2026-10-02 03:19:44 +08: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
SilenceLurker aec7849199 fix(bot/qq): restore approval keyboard delivery and text fallback 2026-10-01 19:10:24 +08: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 30f5ff1e2b Merge generated metadata refresh into remote session badges 2026-10-01 16:05:20 +08:00
XTLine eaafef675b fix(desktop): refresh generated metadata after module extraction 2026-10-01 16:04:32 +08:00
XTLine a308987f1b Merge branch 'fix/remote-switch-history-stall' into fix/remote-session-badges 2026-10-01 15:08:52 +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
XTLine cede083695 fix(remote): route identity runtime badge updates 2026-10-01 11:10:41 +08:00
XTLine 9168b3439d test(remote): cover identity runtime badge routing 2026-10-01 11:07:57 +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 430010cc40 fix(desktop): align remote session read badges 2026-10-01 00:48:14 +08:00
XTLine 7ca9dc39b0 test(desktop): cover remote session read activity 2026-10-01 00:48:14 +08:00
XTLine 2c04fece87 fix(desktop): paint resident remote sessions on switch 2026-10-01 00:48:13 +08:00
XTLine 0b3cad44cc fix(remote): fence stale session badge updates 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 6259f77bd0 test(remote): cover session switch badge states 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
1176 changed files with 50741 additions and 42327 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
+25 -7
View File
@@ -74,14 +74,32 @@ jobs:
node scripts/verify-release-artifact-archive.mjs "$RUNNER_TEMP/payload-artifact.json" "$RUNNER_TEMP/payload.zip"
mkdir payload
unzip -q "$RUNNER_TEMP/payload.zip" -d payload
# One provenance check per payload file; each is a network round trip,
# so they run eight at a time and any single failure fails the step.
signer_digest="$CONTROL_SHA"
find payload -type f -print0 | xargs -0 -P 8 -I{} \
gh attestation verify {} --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
--deny-self-hosted-runners >/dev/null
# Each check retries on its own, because the public-good verifier can fail
# to initialise on a shared runner; the first runs alone so the others
# find the trust cache built, then the rest run eight at a time and any
# single failure fails the step.
verify_provenance() {
local attempt=1
until gh attestation verify "$1" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
--deny-self-hosted-runners >/dev/null; do
test "$attempt" -lt 5 || return 1
attempt=$((attempt + 1))
sleep 6
done
}
export -f verify_provenance
export signer_digest
files=()
while IFS= read -r -d '' file; do files+=("$file"); done < <(find payload -type f -print0)
test "${#files[@]}" -gt 0
verify_provenance "${files[0]}"
if [ "${#files[@]}" -gt 1 ]; then
# shellcheck disable=SC2016 # the child shell must expand "$1" itself
printf '%s\0' "${files[@]:1}" | xargs -0 -P 8 -I{} bash -c 'verify_provenance "$1"' _ {}
fi
node scripts/release-candidate.mjs verify-rehearsal payload record/record.json >/dev/null
git fetch origin main-v2
git merge-base --is-ancestor "${{ steps.inspected.outputs.source_sha }}" origin/main-v2
+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
+14 -13
View File
@@ -8,8 +8,8 @@ name: Release desktop
# build/ fans out to one native runner per platform. Artifacts are
# minisign-signed (MINISIGN_* secrets), a latest.json manifest is generated, and
# everything is published to a GitHub release and mirrored to R2 (the updater
# reads R2 first, then the crash worker release gateway; stable desktop releases
# own GitHub's repository-wide "latest").
# reads R2 first, then the crash worker release gateway; no desktop release
# claims GitHub's repository-wide "latest").
#
# Historical workflow-call inputs still understand Preview artifacts so old
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
@@ -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
@@ -1233,8 +1237,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
# Desktop releases never claim repository-wide latest; the updater
# reads R2 and the release gateway, not GitHub's latest shortcut.
run: >-
bash release-control/scripts/publish-desktop-github-release.sh
"${{ needs.resolve.outputs.tag }}"
@@ -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' }}
@@ -1496,6 +1499,12 @@ jobs:
if [ -n "$existing_keys" ] && [ "$existing_keys" != "None" ]; then
aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$existing_directory/" \
--recursive --endpoint-url "$ENDPOINT"
# A signature whose payload never finished uploading is an unfinished
# write, not published content: the candidate's pair fills it below.
# A signature that has its payload is never touched.
while IFS= read -r -d '' signature; do
[ -f "${signature%.minisig}" ] || rm -f -- "$signature"
done < <(find "$existing_directory" -type f -name '*.minisig' -print0)
verify_signature_directory "$existing_directory"
if [ -f "$existing_directory/latest.json" ]; then
existing_manifest=true
@@ -1818,11 +1827,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 }}
+42 -15
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:
@@ -120,14 +122,32 @@ jobs:
SOURCE_SHA: ${{ steps.record.outputs.source_sha }}
run: |
set -euo pipefail
# One provenance check per payload file; each is a network round trip,
# so they run eight at a time and any single failure fails the step.
signer_digest="${{ steps.record.outputs.candidate_control_sha }}"
find payload -type f -print0 | xargs -0 -P 8 -I{} \
gh attestation verify {} --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
--deny-self-hosted-runners >/dev/null
# Each check retries on its own, because the public-good verifier can fail
# to initialise on a shared runner; the first runs alone so the others
# find the trust cache built, then the rest run eight at a time and any
# single failure fails the step.
verify_provenance() {
local attempt=1
until gh attestation verify "$1" --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
--deny-self-hosted-runners >/dev/null; do
test "$attempt" -lt 5 || return 1
attempt=$((attempt + 1))
sleep 6
done
}
export -f verify_provenance
export signer_digest
files=()
while IFS= read -r -d '' file; do files+=("$file"); done < <(find payload -type f -print0)
test "${#files[@]}" -gt 0
verify_provenance "${files[0]}"
if [ "${#files[@]}" -gt 1 ]; then
# shellcheck disable=SC2016 # the child shell must expand "$1" itself
printf '%s\0' "${files[@]:1}" | xargs -0 -P 8 -I{} bash -c 'verify_provenance "$1"' _ {}
fi
node scripts/release-candidate.mjs verify payload record/record.json >/dev/null
git fetch origin main-v2 --tags
git merge-base --is-ancestor "$SOURCE_SHA" origin/main-v2
@@ -146,12 +166,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 +333,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher
@@ -326,7 +352,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 +360,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
+39 -25
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 \
@@ -548,17 +559,14 @@ jobs:
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
# Verifies what the CLI release actually serves after the compatibility
# upload: exactly the approved asset set, each uploaded, with the size and
# sha256 digest GitHub recorded, SHA256SUMS downloaded from the release,
# and the compatibility latest.json byte-identical to the one uploaded
# above. Any mismatch or API failure fails the job.
- name: Verify published CLI release assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
@@ -573,18 +581,19 @@ jobs:
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
release_json=/tmp/published-cli-release.json
checksums=/tmp/published-cli-SHA256SUMS
gh api "repos/${{ github.repository }}/releases/tags/$TAG" >"$release_json"
gh release download "$TAG" -R "${{ github.repository }}" \
--pattern SHA256SUMS --output "$checksums" --clobber
decision="$(bash release-control/scripts/decide-cli-release-publication.sh \
stable "$TAG" "${{ github.repository }}" "$release_json" "$checksums")"
test "$decision" = "reuse"
jq -e '[.assets[].name] | index("latest.json") != null' "$release_json" >/dev/null
want="sha256:$(sha256sum latest.json | cut -d' ' -f1)"
got="$(jq -r '.assets[] | select(.name == "latest.json") | .digest' "$release_json")"
test "$want" = "$got"
echo "published CLI release $TAG assets verified against SHA256SUMS and the uploaded manifest"
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
@@ -607,6 +616,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 +630,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
+6
View File
@@ -64,6 +64,12 @@ linters:
linters:
- staticcheck
text: SA5011
# x/net's Transport still returns GoAwayError although the type is marked
# deprecated, so the classifier has to keep recognising it.
- path: internal/provider/transport_diagnostic
linters:
- staticcheck
text: SA1019
# SA4023 is trivially true on GOOS=windows: dialAgent's Windows stub
# (agent_windows.go) always returns a non-nil error until named-pipe
# transport lands. Suppress only this call site; the comparison stays
+6
View File
@@ -24,6 +24,12 @@ branch.
### Added
- **Discover Reasonix Studio (Desktop 1.x):** a one-time introduction on an idle
stable desktop launch, plus a permanent entry in **Settings → About**. Opens
the official Studio download page and explains coexistence and data limits;
it does not install Studio or change the 1.x updater. See
[the introduction guide](docs/STUDIO_DISCOVERY.md).
- **Editable message queue (Desktop):** full-text editing in the original queue row,
pointer and keyboard reordering, move-to-top/bottom actions, and a pause
control. Saves preserve message identity, order and attachments; conflicting
+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)
+150
View File
@@ -0,0 +1,150 @@
// provider-network-probe performs one bounded, synthetic Chat Completions call.
// It never executes tools and outputs only the provider's safe diagnostic model.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"reasonix/internal/provider"
"reasonix/internal/secrets"
)
type singleTransport struct {
base http.RoundTripper
used atomic.Bool
}
func (t *singleTransport) RoundTrip(r *http.Request) (*http.Response, error) {
if t.used.Swap(true) {
return nil, errors.New("probe request limit reached")
}
return t.base.RoundTrip(r)
}
func main() {
endpoint := flag.String("endpoint", "https://api.deepseek.com/chat/completions", "complete endpoint URL; no credentials or query")
model := flag.String("model", "deepseek-v4-flash", "fixed model for the comparison")
protocol := flag.String("protocol", "h2", "h1 or h2; observed protocol is included in output")
bodyBytes := flag.Int("body-bytes", 1024, "target synthetic JSON bytes (1024..4194304)")
expect := flag.Bool("expect-continue", false, "independent Expect: 100-continue experiment")
budget := flag.Duration("timeout", 360*time.Second, "whole probe budget (1s..10m)")
flag.Parse()
if err := run(*endpoint, *model, *protocol, *bodyBytes, *expect, *budget); err != nil {
// Validation errors are fixed local prose, never transport error strings.
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(endpoint, model, protocol string, bodyBytes int, expect bool, budget time.Duration) error {
u, err := url.Parse(endpoint)
if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http") {
return errors.New("endpoint must be an HTTP(S) URL without credentials, query or fragment")
}
if protocol != "h1" && protocol != "h2" {
return errors.New("protocol must be h1 or h2")
}
if bodyBytes < 1024 || bodyBytes > 4<<20 || budget < time.Second || budget > 10*time.Minute {
return errors.New("body size or timeout outside probe limits")
}
key := os.Getenv("REASONIX_PROBE_API_KEY")
if key == "" {
return errors.New("set REASONIX_PROBE_API_KEY in the environment")
}
if u.Scheme != "https" && u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1" {
return errors.New("remote probes require HTTPS")
}
body := syntheticBody(model, bodyBytes)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = (&net.Dialer{Timeout: 30 * time.Second}).DialContext
tr.TLSHandshakeTimeout = 30 * time.Second
tr.ResponseHeaderTimeout = 300 * time.Second
tr.ExpectContinueTimeout = time.Second
tr.Protocols = new(http.Protocols)
tr.Protocols.SetHTTP1(protocol == "h1")
tr.Protocols.SetHTTP2(protocol == "h2")
defer tr.CloseIdleConnections()
client := &http.Client{Transport: &singleTransport{base: tr}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
ctx, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
var mu sync.Mutex
var observation provider.RequestObservation
ctx = provider.WithRequestObserver(ctx, func(v provider.RequestObservation) { mu.Lock(); observation = v; mu.Unlock() })
resp, requestErr := provider.SendOnce(ctx, client, provider.SendOptions{Provider: "probe", Protocol: "openai"}, func(ctx context.Context) (*http.Request, error) {
r, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
// Prevent request-body replay inside Transport. A new transport is used
// for this invocation; the wrapper also rejects any application resend.
r.GetBody = nil
r.Header.Set("Authorization", "Bearer "+key)
r.Header.Set("Content-Type", "application/json")
if expect {
r.Header.Set("Expect", "100-continue")
}
return r, nil
})
traceID := ""
if resp != nil {
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("x-trace-id")}).TraceID
if traceID == "" {
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("trace-id")}).TraceID
}
if traceID == "" {
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("trace_id")}).TraceID
}
_, readErr := io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if requestErr == nil {
requestErr = readErr
}
}
mu.Lock()
result := struct {
SchemaVersion int `json:"schemaVersion"`
RequestedProtocol string `json:"requestedProtocol"`
ExpectContinue bool `json:"expectContinue"`
TraceID string `json:"traceId,omitempty"`
Request provider.RequestObservation `json:"request"`
Failure *provider.FailureDiagnostic `json:"failure,omitempty"`
}{1, protocol, expect, traceID, observation, nil}
mu.Unlock()
if requestErr != nil {
d := provider.DiagnoseFailure(requestErr)
result.Failure = d
}
encoded, err := json.Marshal(result)
if err != nil {
return errors.New("cannot encode safe probe result")
}
encoded, err = secrets.RedactJSON(encoded)
if err != nil || !json.Valid(encoded) {
return errors.New("cannot redact probe result")
}
fmt.Println(string(encoded))
return nil
}
func syntheticBody(model string, size int) []byte {
request := map[string]any{"model": model, "stream": true, "max_tokens": 1, "messages": []map[string]string{{"role": "user", "content": "Reply OK. Synthetic padding: "}}}
base, _ := json.Marshal(request)
if padding := size - len(base); padding > 0 {
request["messages"].([]map[string]string)[0]["content"] += strings.Repeat("x", padding)
}
body, _ := json.Marshal(request)
return body
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestSyntheticProbeIsBoundedAndDoesNotFollowRedirects(t *testing.T) {
for _, size := range []int{1024, 3_248_572} {
body := syntheticBody("test-model", size)
var payload map[string]any
if len(body) != size || json.Unmarshal(body, &payload) != nil || payload["max_tokens"] != float64(1) || payload["tools"] != nil {
t.Fatalf("invalid synthetic request length=%d", len(body))
}
}
var calls atomic.Int32
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
io.Copy(io.Discard, r.Body)
http.Redirect(w, r, "/redirect", http.StatusTemporaryRedirect)
}))
defer s.Close()
t.Setenv("REASONIX_PROBE_API_KEY", "local-only-key")
if err := run(s.URL, "test-model", "h1", 1024, false, time.Second); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("calls=%d", calls.Load())
}
tr := &singleTransport{base: http.DefaultTransport}
tr.used.Store(true)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL, nil)
if _, err := tr.RoundTrip(req); err == nil || calls.Load() != 1 {
t.Fatal("probe allowed a retry")
}
}
+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)
}
+5 -6
View File
@@ -156,10 +156,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Electron's Chromium can't
cross-compile), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
`latest.json` manifest, publishes a GitHub release that does not claim the repository-wide `Latest`
badge, mirrors everything to R2, and attaches the current desktop manifest to
the matching CLI release for old clients.
The Linux artifact bundles Electron's Chromium and ships a root-owned
`chrome-sandbox` helper in the `.deb`; no system webview is required.
@@ -193,8 +192,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()}), "")
+6 -4
View File
@@ -426,7 +426,7 @@ func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
}
}
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
func TestAttachDroppedOutsideWorkspaceReferencesFile(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
@@ -439,8 +439,10 @@ func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root}},
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root, Ctrl: c}},
activeTabID: "project",
}
@@ -448,8 +450,8 @@ func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
t.Fatalf("got %+v, want copied pdf attachment", got)
if got.Kind != "workspace" || !filepath.IsAbs(got.Path) || got.DisplayPath == "" {
t.Fatalf("got %+v, want exact file reference", got)
}
}
@@ -0,0 +1,56 @@
package main
import (
"encoding/base64"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
func TestStaleAttachmentCleanupDoesNotFollowSwappedDirectory(t *testing.T) {
a, root, _ := uploadFixture(t)
outside := t.TempDir()
attachments := filepath.Join(root, ".reasonix", "attachments")
want := []byte("external victim must survive stale attachment cleanup")
var victim string
a.attachmentIOHook = func() {
entries, err := os.ReadDir(attachments)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].IsDir() {
t.Fatalf("saved attachment entries = %v", entries)
}
victim = filepath.Join(outside, entries[0].Name())
if err := os.WriteFile(victim, want, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Rename(attachments, filepath.Join(root, ".reasonix", "original-attachments")); err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", attachments, outside).CombinedOutput(); err != nil {
t.Fatalf("create attachment junction: %v: %s", err, out)
}
} else if err := os.Symlink(outside, attachments); err != nil {
t.Fatal(err)
}
a.mu.Lock()
a.tabs["a"].SessionGeneration++
a.mu.Unlock()
}
data := "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString([]byte("saved APK fixture"))
if ref, err := a.SavePastedFileForTab("a", "app.apk", data); err == nil || ref != "" {
t.Fatalf("stale write returned ref %q, err %v", ref, err)
}
got, err := os.ReadFile(victim)
if err != nil {
t.Fatalf("stale-owner cleanup deleted the external victim through the swapped attachment directory: %v", err)
}
if string(got) != string(want) {
t.Fatalf("external victim changed: %q", got)
}
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"reasonix/internal/config"
"reasonix/internal/control"
)
const attachmentUploadPath = "/__reasonix_attachment_upload"
func (a *App) attachmentUploadMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != attachmentUploadPath {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Content-Type") != "application/octet-stream" {
http.Error(w, "unsupported upload type", http.StatusUnsupportedMediaType)
return
}
target, err := a.attachmentTargetToken(r.URL.Query().Get("target"))
if err != nil {
http.Error(w, "attachment target is unavailable", http.StatusConflict)
return
}
limit, err := config.LoadAttachmentStreamLimit()
if err != nil {
http.Error(w, "attachment upload configuration could not be read", http.StatusInternalServerError)
return
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
stop := context.AfterFunc(a.attachmentOperationContext(target), func() { cancel(); _ = r.Body.Close() })
defer stop()
rel, err := control.SaveAttachmentStreamInRoot(ctx, target.root, r.URL.Query().Get("name"), r.Body, limit)
rel, err = a.finishAttachmentWrite(target, rel, err)
if err != nil {
if !a.attachmentTargetCurrent(target) {
http.Error(w, "attachment upload could not be saved", http.StatusConflict)
return
}
var limitErr *control.AttachmentStreamLimitError
if errors.As(err, &limitErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(struct {
Code string `json:"code"`
Message string `json:"message"`
LimitBytes int64 `json:"limit_bytes"`
LimitSource string `json:"limit_source"`
}{"attachment.too_large", limitErr.Error(), limitErr.LimitBytes, limitErr.LimitSource})
return
}
http.Error(w, "attachment upload could not be saved", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Path string `json:"path"`
}{Path: rel})
})
}
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/config"
)
func TestAttachmentUploadEnforcesUserStreamLimitAndCleansPartialFile(t *testing.T) {
for _, tc := range []struct {
name, user, project, body string
length int64
status int
}{
{"exact", "4", "", "1234", 0, http.StatusOK},
{"plus one", "4", "", "12345", 0, http.StatusRequestEntityTooLarge},
{"unknown length", "4", "", "12345", -1, http.StatusRequestEntityTooLarge},
{"misleading length", "4", "", "12345", 1, http.StatusRequestEntityTooLarge},
{"project cannot disable", "4", "0", "12345", -1, http.StatusRequestEntityTooLarge},
{"user unlimited", "0", "1", "123456", -1, http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
app, root, token := uploadFixture(t)
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = "+tc.user+"\n"), 0600); err != nil {
t.Fatal(err)
}
if tc.project != "" {
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[attachments]\nmax_stream_bytes = "+tc.project+"\n"), 0600); err != nil {
t.Fatal(err)
}
}
req := uploadRequest(token, strings.NewReader(tc.body))
if tc.length != 0 {
req.ContentLength = tc.length
}
recorder := httptest.NewRecorder()
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, req)
if recorder.Code != tc.status {
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tc.status, recorder.Body)
}
if tc.status != http.StatusRequestEntityTooLarge {
return
}
var result struct {
Code string `json:"code"`
Message string `json:"message"`
LimitBytes int64 `json:"limit_bytes"`
LimitSource string `json:"limit_source"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code != "attachment.too_large" || result.Message == "" || result.LimitBytes != 4 || result.LimitSource != "user" {
t.Fatalf("untyped limit error: %+v", result)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("partial files=%v err=%v", entries, err)
}
})
}
}
func TestAttachmentUploadRejectsUnreadableUserLimitBeforeCreatingFile(t *testing.T) {
app, root, token := uploadFixture(t)
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = [broken"), 0600); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("body")))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("configuration failure accepted upload: %d %s", recorder.Code, recorder.Body)
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("configuration failure created attachment files: %v", err)
}
}
type uploadLimitMutationReader struct {
body *strings.Reader
mutate func()
}
func (reader *uploadLimitMutationReader) Read(p []byte) (int, error) {
if reader.mutate != nil {
reader.mutate()
reader.mutate = nil
}
return reader.body.Read(p)
}
func TestAttachmentUploadRetiredOwnerWinsOverStreamLimit(t *testing.T) {
app, root, token := uploadFixture(t)
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = 4\n"), 0600); err != nil {
t.Fatal(err)
}
reader := &uploadLimitMutationReader{body: strings.NewReader("12345"), mutate: func() { app.tabs["a"].SessionGeneration++ }}
recorder := httptest.NewRecorder()
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, reader))
if recorder.Code != http.StatusConflict {
t.Fatalf("retired owner error=%d body=%s", recorder.Code, recorder.Body)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("partial files=%v err=%v", entries, err)
}
}
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/control"
)
func uploadFixture(t *testing.T) (*App, string, string) {
t.Helper()
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
if err := os.WriteFile(filepath.Join(home, "config.toml"), nil, 0600); err != nil {
t.Fatal(err)
}
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
a := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
return a, root, target.Token
}
type uploadZeros struct{}
func (uploadZeros) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
func uploadRequest(token string, body io.Reader) *http.Request {
query := url.Values{"target": {token}, "name": {"large app.apk"}}
r := httptest.NewRequest(http.MethodPost, attachmentUploadPath+"?"+query.Encode(), body)
r.Header.Set("Content-Type", "application/octet-stream")
r.Header.Set("Authorization", "Bearer test-secret")
return r
}
func TestAttachmentUploadLargeFileKeepsCapturedWorkspace(t *testing.T) {
a, root, token := uploadFixture(t)
other := t.TempDir()
a.tabs["b"] = &WorkspaceTab{ID: "b", WorkspaceRoot: other}
a.activeTabID = "b"
const size = 80 << 20
recorder := httptest.NewRecorder()
handler := bearerAuth("test-secret", a.attachmentUploadMiddleware()(http.NotFoundHandler()))
handler.ServeHTTP(recorder, uploadRequest(token, io.LimitReader(uploadZeros{}, size)))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body)
}
var result struct {
Path string `json:"path"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(result.Path)))
if err != nil || info.Size() != size {
t.Fatalf("file=%v err=%v", info, err)
}
if !strings.HasSuffix(result.Path, ".apk") {
t.Fatalf("path=%s", result.Path)
}
if _, err := os.Stat(filepath.Join(other, filepath.FromSlash(result.Path))); !os.IsNotExist(err) {
t.Fatalf("wrote into focused tab: %v", err)
}
}
func TestAttachmentUploadRejectsUntrustedAndStaleRequests(t *testing.T) {
a, root, token := uploadFixture(t)
handler := bearerAuth("test-secret", a.attachmentUploadMiddleware()(http.NotFoundHandler()))
for _, scenario := range []string{"no auth", "bad type", "bad method", "unknown target", "released target"} {
t.Run(scenario, func(t *testing.T) {
r := uploadRequest(token, strings.NewReader("body"))
want := http.StatusConflict
switch scenario {
case "no auth":
r.Header.Del("Authorization")
want = http.StatusUnauthorized
case "bad type":
r.Header.Set("Content-Type", "text/plain")
want = http.StatusUnsupportedMediaType
case "bad method":
r.Method = http.MethodGet
want = http.StatusMethodNotAllowed
case "unknown target":
r.URL.RawQuery = "target=not-a-token"
case "released target":
a.ReleaseAttachmentTarget(token)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, r)
if recorder.Code != want {
t.Fatalf("status=%d want=%d", recorder.Code, want)
}
})
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("rejected upload created files: %v", err)
}
}
func TestAttachmentUploadRemovesFileWhenTargetChangesBeforeCommit(t *testing.T) {
a, root, token := uploadFixture(t)
a.attachmentIOHook = func() { a.tabs["a"].SessionGeneration++ }
recorder := httptest.NewRecorder()
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("partial")))
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d", recorder.Code)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("left partial files: %v err=%v", entries, err)
}
}
func TestAttachmentUploadCancellationClosesBlockedBodyAndCleansFile(t *testing.T) {
a, root, token := uploadFixture(t)
reader, writer := io.Pipe()
defer writer.Close()
done := make(chan struct{})
go func() {
defer close(done)
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(httptest.NewRecorder(), uploadRequest(token, reader))
}()
if _, err := writer.Write(make([]byte, 64<<10)); err != nil {
t.Fatal(err)
}
a.ReleaseAttachmentTarget(token)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("cancelled upload kept reading its body")
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("files=%v err=%v", entries, err)
}
}
func TestAttachmentUploadLateCancellationRemovesCompletedFile(t *testing.T) {
a, root, token := uploadFixture(t)
a.attachmentIOHook = func() { a.ReleaseAttachmentTarget(token) }
recorder := httptest.NewRecorder()
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("complete but cancelled")))
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d", recorder.Code)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
if err != nil || len(entries) != 0 {
t.Fatalf("files=%v err=%v", entries, err)
}
}
func TestDroppedLargeExternalFileRemainsReference(t *testing.T) {
a, root, token := uploadFixture(t)
path := filepath.Join(t.TempDir(), "large app.apk")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(120 << 20); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
item, err := a.AttachDroppedForTarget(token, path)
if err != nil {
t.Fatal(err)
}
if item.Kind != "workspace" || !filepath.IsAbs(item.Path) || item.IsDir {
t.Fatalf("item=%+v", item)
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("native file was copied: %v", err)
}
item2, err := a.AttachDroppedForTarget(token, item.DisplayPath)
if err != nil || item2.Path != item.Path {
t.Fatalf("restored=%+v err=%v", item2, err)
}
}
func TestDraftExternalFileReferenceEscapesSpacesAndPreservesOtherPaths(t *testing.T) {
a, _, _ := uploadFixture(t)
path := filepath.Join(t.TempDir(), "large app.apk")
if err := os.WriteFile(path, []byte("PK\x00binary"), 0o600); err != nil {
t.Fatal(err)
}
other := "@" + control.EscapeRefPath(path+".backup")
request := SessionDraftSubmissionRequest{Input: "inspect @" + control.EscapeRefPath(path) + " " + other, WorkspaceRefs: []DraftWorkspaceRef{{Path: path, DisplayPath: path}}}
if err := a.resolveDraftExternalRefs("a", &request); err != nil {
t.Fatal(err)
}
canonical, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
want := "inspect @" + control.EscapeRefPath(filepath.ToSlash(canonical)) + " " + other
if request.Input != want {
t.Fatalf("input=%q want=%q", request.Input, want)
}
}
func TestDraftNativeFileReferenceMatchesSessionReference(t *testing.T) {
a, root, _ := uploadFixture(t)
sourceRoot := t.TempDir()
canonicalPath := filepath.Join(sourceRoot, "large app.apk")
if err := os.WriteFile(canonicalPath, []byte("APK"), 0o600); err != nil {
t.Fatal(err)
}
path := canonicalPath
alias := filepath.Join(t.TempDir(), "parent alias")
if err := os.Symlink(sourceRoot, alias); err == nil {
path = filepath.Join(alias, filepath.Base(canonicalPath))
}
item, err := a.attachDroppedForTarget(attachmentTarget{root: root, draftID: "draft"}, path)
if err != nil {
t.Fatal(err)
}
restored, err := a.AttachDroppedForTab("a", item.DisplayPath)
if err != nil {
t.Fatal(err)
}
if item.Path != restored.Path || item.Path != filepath.ToSlash(item.Path) {
t.Fatalf("draft=%+v session=%+v", item, restored)
}
}
+29 -7
View File
@@ -111,6 +111,9 @@ func (a *App) attachmentTargetCurrent(target attachmentTarget) bool {
}
func (a *App) attachmentTargetInvalidReason(target attachmentTarget) string {
if target.ctx != nil && target.ctx.Err() != nil {
return "attachment operation cancelled"
}
if target.tab == nil {
if target.draftID == "" {
return "missing owner"
@@ -165,7 +168,7 @@ func (a *App) finishAttachmentWrite(target attachmentTarget, rel string, err err
return rel, nil
}
if isAttachmentRefPath(rel) {
_ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
_ = control.RemoveStoredAttachment(target.root, rel)
}
return "", fmt.Errorf("attachment workspace changed while saving; please retry")
}
@@ -428,6 +431,9 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
if err != nil {
return DroppedItem{}, err
}
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
return DroppedItem{}, fmt.Errorf("dropped path must be a regular file or directory")
}
if isImageExt(path) {
if rel, saveErr := control.SaveImageFileInRoot(target.root, path); saveErr == nil {
rel, saveErr = a.finishAttachmentWrite(target, rel, nil)
@@ -436,7 +442,7 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
}
preview, previewErr := a.attachmentDataURLForTarget(target, rel)
if previewErr != nil {
_ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
_ = control.RemoveStoredAttachment(target.root, rel)
return DroppedItem{}, previewErr
}
return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil
@@ -450,6 +456,20 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
}
return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil
}
if target.draftID != "" {
abs, err := filepath.Abs(path)
if err != nil {
return DroppedItem{}, err
}
if !info.IsDir() {
abs, err = filepath.EvalSymlinks(abs)
if err != nil {
return DroppedItem{}, err
}
abs = filepath.ToSlash(abs)
}
return DroppedItem{Kind: "workspace", Path: abs, IsDir: info.IsDir(), DisplayPath: filepath.ToSlash(abs)}, nil
}
if info.IsDir() {
if target.tab == nil || target.ctrl == nil {
return DroppedItem{}, fmt.Errorf("workspace is not ready")
@@ -473,15 +493,17 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
}
return DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}, nil
}
rel, err := control.SaveAttachmentFileInRoot(target.root, path)
if target.ctrl == nil || !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("workspace is not ready")
}
token, displayPath, err := target.ctrl.RegisterExternalFileRef(path)
if err != nil {
return DroppedItem{}, err
}
rel, err = a.finishAttachmentWrite(target, rel, nil)
if err != nil {
return DroppedItem{}, err
if !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
return DroppedItem{Kind: "attachment", Path: rel}, nil
return DroppedItem{Kind: "workspace", Path: token, DisplayPath: displayPath}, nil
}
func isImageExt(path string) bool {
+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
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/session"
"reasonix/internal/tool"
)
type compactionAdmissionSink struct {
*tabEventSink
states chan event.RuntimeStateSnapshot
}
func (s *compactionAdmissionSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
s.states <- state
}
func awaitCompactionIdle(t *testing.T, ctrl *control.Controller, states <-chan event.RuntimeStateSnapshot) {
t.Helper()
timeout := time.After(10 * time.Second)
for {
select {
case state := <-states:
if !state.Running && state.Maintenance == nil && !ctrl.RuntimeStatus().Running {
return
}
case <-timeout:
t.Fatal("maintenance did not release its runtime owner")
}
}
}
func TestCompactionAdmissionReleasesManagementReservation(t *testing.T) {
for _, name := range []string{"receipt", "legacy", "display"} {
t.Run(name, func(t *testing.T) {
prov := &compactReceiptProvider{started: make(chan struct{}, 8), release: make(chan struct{}, 8)}
sess := agent.NewSession("system")
for range 8 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("question ", 300)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("answer ", 300)})
}
sink := &compactionAdmissionSink{tabEventSink: &tabEventSink{tabID: "compact"}, states: make(chan event.RuntimeStateSnapshot, 128)}
exec := agent.New(prov, tool.NewRegistry(), sess, agent.Options{ContextWindow: 32_000}, sink)
dir := t.TempDir()
service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(dir, "sessions-v5")))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "compact"})
if err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{Runner: exec, Executor: exec, Sink: sink, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
cleanupExactTurnController(t, ctrl)
tab := &WorkspaceTab{ID: "compact", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink.tabEventSink}
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
submit := func(input, id string) error {
switch name {
case "legacy":
return app.SubmitToTabWithID(tab.ID, input, id)
case "display":
return app.SubmitDisplayToTabWithID(tab.ID, input, input, id)
}
_, err := app.StartTurnForTab(tab.ID, input, id)
return err
}
for cycle := range 2 {
if err := submit("/compact preserve decisions", "compact"); err != nil {
t.Fatal(err)
}
select {
case <-prov.started:
case <-time.After(10 * time.Second):
t.Fatal("summary provider did not start")
}
if _, err := app.StartTurnForTab(tab.ID, "must remain blocked", "blocked"); !errors.Is(err, control.ErrTurnRunning) {
t.Fatalf("active maintenance lost admission fence: %v", err)
}
prov.release <- struct{}{}
awaitCompactionIdle(t, ctrl, sink.states)
if !sink.tryBeginTurn("probe") {
t.Fatal("completed maintenance retained desktop turn reservation")
}
sink.cancelTurnStart()
// A new send must reach the provider and supply history for compaction.
if err := submit(strings.Repeat("new task detail ", 500), fmt.Sprintf("next-%d", cycle)); err != nil {
t.Fatalf("next send: %v", err)
}
select {
case <-prov.started:
case <-time.After(10 * time.Second):
t.Fatal("next send did not reach provider")
}
prov.release <- struct{}{}
awaitCompactionIdle(t, ctrl, sink.states)
}
})
}
}
func TestManagementAdmissionDoesNotReleaseStartedTurn(t *testing.T) {
sink := &tabEventSink{}
if !sink.tryBeginTurn("management") {
t.Fatal("reserve management")
}
sink.Emit(event.Event{Kind: event.TurnStarted})
sink.cancelTurnStart()
if sink.tryBeginTurn("new") {
t.Fatal("management completion released an independently started turn")
}
sink.Emit(event.Event{Kind: event.TurnDone})
if !sink.tryBeginTurn("new") {
t.Fatal("completed turn retained reservation")
}
}
func TestManagementAdmissionPreservesTurnDoneFanout(t *testing.T) {
sink := &tabEventSink{}
gate := &turnFanoutGate{kind: event.TurnDone, entered: make(chan struct{}), release: make(chan struct{})}
sink.SetBotSink(gate)
if !sink.tryBeginTurn("management") {
t.Fatal("reserve management")
}
sink.Emit(event.Event{Kind: event.TurnStarted})
done := make(chan struct{})
go func() { sink.Emit(event.Event{Kind: event.TurnDone}); close(done) }()
select {
case <-gate.entered:
case <-time.After(10 * time.Second):
t.Fatal("TurnDone fan-out did not start")
}
sink.cancelTurnStart()
admitted := sink.tryBeginTurn("new")
close(gate.release)
<-done
if admitted {
t.Fatal("management completion bypassed TurnDone fan-out fence")
}
if !sink.tryBeginTurn("new") {
t.Fatal("settled fan-out retained reservation")
}
}
+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)
}
+2
View File
@@ -11,6 +11,8 @@
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.preload.json",
"test": "node scripts/build-browser-page.mjs && node --import tsx --test \"src/**/*.test.ts\"",
"test:graphics-recovery": "node scripts/graphics-recovery-smoke.mjs",
"test:attachments": "node scripts/attachment-smoke.mjs",
"test:composer-close": "node scripts/composer-close-smoke.mjs",
"start": "node scripts/build.mjs && node scripts/start.mjs",
"dev": "node scripts/build.mjs && node scripts/start.mjs --dev",
"smoke": "node scripts/build.mjs && node scripts/smoke.mjs"
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, openSync, ftruncateSync, closeSync, statSync, writeFileSync, readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { _electron } from "playwright";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const home = mkdtempSync(join(tmpdir(), "reasonix-attachment-native-"));
writeFileSync(join(home, "config.toml"), "");
const workspace = join(home, "workspace");
mkdirSync(workspace);
const nativeFiles = ["large app.apk", "pasted app.apk", "dropped app.apk"].map(name => {
const path = join(home, name);
const fd = openSync(path, "w");
ftruncateSync(fd, 120 * 1024 * 1024);
closeSync(fd);
return path;
});
const [apk] = nativeFiles;
const report = { checks: [], errors: [] };
const check = name => { report.checks.push(name); console.log(`PASS ${name}`); };
const invoke = (page, name, args = []) => page.evaluate(({ name, args }) => window.reasonixDesktop.invoke(name, args), { name, args });
async function until(predicate) {
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error("Attachment smoke condition timed out");
}
const shell = await _electron.launch({ executablePath: process.env.REASONIX_TEST_ELECTRON, args: [root, `--user-data-dir=${join(home, "electron")}`], env: {
...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: join(home, "cache"),
REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: process.env.REASONIX_DESKTOP_SERVICE || resolve(root, "../build/bin/reasonix-desktop-service" + (process.platform === "win32" ? ".exe" : "")),
}, timeout: 60000 });
try {
const page = await shell.firstWindow();
page.setDefaultTimeout(30000);
page.on("pageerror", error => report.errors.push(error.message));
await page.waitForURL("reasonix://app/index.html", { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForFunction(() => Boolean(window.reasonixDesktop) && !document.querySelector(".boot-shell"), null, { timeout: 60000 });
await until(async () => { try { await invoke(page, "Version"); return true; } catch { return false; } });
const operationId = "attachment-native-smoke";
await invoke(page, "BeginManualSessionCreation", [{ operationId, scope: "project", workspaceRoot: workspace }]);
await until(async () => {
const creation = await invoke(page, "GetManualSessionCreation", [operationId]);
if (creation.phase === "failed") throw new Error(creation.error);
return creation.phase === "ready";
});
const creation = await invoke(page, "GetManualSessionCreation", [operationId]);
report.session = creation.ref;
const tabs = await invoke(page, "ListTabs");
const tab = tabs.find(item => item.sessionId === creation.ref.sessionId);
assert.ok(tab, JSON.stringify(tabs));
await invoke(page, "SetActiveTab", [tab.id]);
await page.reload();
const input = page.locator(".composer-content-file-input");
await input.waitFor({ state: "attached", timeout: 60000 });
await page.waitForFunction(() => !document.querySelector(".composer-content-file-input")?.disabled);
await page.locator(".startup-splash").waitFor({ state: "hidden", timeout: 60000 });
await page.locator("#composer-input").waitFor({ state: "visible" });
let uploads = 0;
page.on("request", request => { if (request.url().includes("/__reasonix_attachment_upload")) uploads++; });
await input.setInputFiles(apk);
await until(async () => {
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
return JSON.parse(state.contentJson).workspaceRefs?.some(item => item.path.endsWith("large app.apk"));
});
assert.equal(uploads, 0);
assert.equal(statSync(apk).size, 120 * 1024 * 1024);
check("120 MiB native picker adds a path reference without an HTTP upload");
await page.evaluate(() => {
const source = document.createElement("input");
source.type = "file";
source.id = "attachment-smoke-source";
document.body.append(source);
});
for (const [index, kind] of ["paste", "drop"].entries()) {
await page.locator("#attachment-smoke-source").setInputFiles(nativeFiles[index + 1]);
await page.evaluate(kind => {
const transfer = new DataTransfer();
transfer.items.add(document.querySelector("#attachment-smoke-source").files[0]);
const event = kind === "paste"
? new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true })
: new DragEvent("drop", { dataTransfer: transfer, bubbles: true });
document.querySelector("#composer-input").dispatchEvent(event);
}, kind);
await until(async () => {
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
return JSON.parse(state.contentJson).workspaceRefs?.length === index + 2;
});
assert.equal(uploads, 0);
check(`120 MiB native ${kind} adds a path reference without an HTTP upload`);
}
await page.evaluate(() => {
const transfer = new DataTransfer();
const chunk = new Uint8Array(64 * 1024).fill(37);
transfer.items.add(new File(Array(1280).fill(chunk), "virtual.apk", { type: "application/vnd.android.package-archive" }));
const input = document.querySelector(".composer-content-file-input");
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await until(async () => {
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
return JSON.parse(state.contentJson).attachments?.some(item => item.displayName === "virtual.apk");
});
const saved = JSON.parse((await invoke(page, "GetSessionComposerState", [creation.ref])).contentJson);
const uploaded = saved.attachments.find(item => item.displayName === "virtual.apk");
const stored = join(workspace, uploaded.path);
assert.equal(statSync(stored).size, 80 * 1024 * 1024);
assert.equal(uploads, 1);
assert.equal(readFileSync(stored).every(byte => byte === 37), true);
check("80 MiB pathless File streams through the real Electron protocol and Go service byte-for-byte");
writeFileSync(join(home, "config.toml"), "[attachments]\nmax_stream_bytes = 4\n");
const rejected = page.waitForResponse(response => response.url().includes("/__reasonix_attachment_upload") && response.status() === 413);
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(new File(["12345"], "oversize.apk"));
const input = document.querySelector(".composer-content-file-input");
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const failure = await (await rejected).json();
assert.equal(failure.code, "attachment.too_large");
assert.equal(failure.limit_bytes, 4);
assert.equal(failure.limit_source, "user");
try {
const notice = page.locator(".toast--warn").filter({ hasText: "oversize.apk" });
await notice.waitFor({ state: "visible" });
assert.ok((await notice.innerText()).includes(String(failure.limit_bytes / (1024 * 1024))));
} catch (error) {
writeFileSync(join(home, "limit-failure-dom.txt"), await page.locator("body").innerText());
await page.screenshot({ path: join(home, "limit-failure.png") });
throw error;
}
assert.equal(readdirSync(join(workspace, ".reasonix", "attachments")).length, 1);
check("user byte ceiling returns typed 413, displays its reason, and removes the partial file");
writeFileSync(join(home, "config.toml"), "[attachments]\nmax_stream_bytes = 0\n");
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(new File(["123456"], "unlimited.apk"));
const input = document.querySelector(".composer-content-file-input");
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await until(async () => {
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
return JSON.parse(state.contentJson).attachments?.some(item => item.displayName === "unlimited.apk");
});
assert.equal(uploads, 3);
check("explicit user zero disables the ordinary stream ceiling without restarting");
assert.equal(typeof await invoke(page, "Version"), "string");
assert.equal(report.errors.length, 0, report.errors.join("\n"));
assert.equal(await page.locator("[data-crash-overlay], .error-boundary").count(), 0);
await page.locator("#composer-input").fill("Attachment smoke remains responsive");
assert.equal(await page.locator("#composer-input").inputValue(), "Attachment smoke remains responsive");
await page.screenshot({ path: join(home, "attachments.png") });
check("renderer and service remain responsive after large-file uploads and limit rejection");
} finally {
await shell.close();
writeFileSync(join(home, "attachment-smoke-results.json"), JSON.stringify(report, null, 2));
console.log(`Evidence: ${home}`);
}
@@ -0,0 +1,142 @@
// Native window close + the real renderer and Go store, using disposable data.
// Build frontend, service and shell first; no provider or clipboard is used.
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
import { _electron } from "playwright";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const home = mkdtempSync(join(tmpdir(), "reasonix-composer-close-"));
writeFileSync(join(home, "config.toml"), "");
const report = { home, platform: process.platform, checks: [] };
const check = name => { report.checks.push(name); console.log(`PASS ${name}`); };
const rpc = (page, name, ...args) => page.evaluate(({ name, args }) => window.reasonixDesktop.invoke(name, args), { name, args });
const inputSelector = "textarea.composer__input:not([aria-hidden=true])";
let shell;
let shellStderr = "";
function terminateFixture() {
// Playwright launches a dedicated process group on POSIX. Include its
// helpers so an orphaned network service cannot retain the CDP connection.
try {
if (process.platform === "win32") shell.process().kill("SIGKILL");
else process.kill(-shell.process().pid, "SIGKILL");
} catch (error) { if (error.code !== "ESRCH") throw error; }
}
async function launch() {
shell = await _electron.launch({ args: [root, `--user-data-dir=${join(home, "electron")}`], env: {
...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: join(home, "cache"),
REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: resolve(root, "../build/bin/reasonix-desktop-service" + (process.platform === "win32" ? ".exe" : "")),
}, timeout: 60000 });
shellStderr = "";
shell.process().stderr.on("data", chunk => { shellStderr += chunk; });
const page = await shell.firstWindow();
page.setDefaultTimeout(30000);
await page.waitForURL("reasonix://app/index.html", { timeout: 60000 });
await page.waitForFunction(() => Boolean(window.__reasonixFlushSessionDraft) && !document.querySelector(".boot-shell"));
await page.addLocatorHandler(page.locator(".management-screen__back:visible"), button => button.click());
await page.locator(".sidebar__quick-action:visible").first().click({ trial: true });
return page;
}
async function closeWindow() {
const page = await shell.firstWindow();
const mainWindow = await shell.browserWindow(page);
const closed = page.waitForEvent("close", { timeout: 30000 });
// BrowserWindow.close dispatches the same native close event as the titlebar.
await mainWindow.evaluate(win => win.close());
await closed;
// Native shutdown must destroy the window first. Only then detach
// Playwright's Node inspector, which otherwise keeps Electron alive at exit.
const closing = shell.close();
const detached = await Promise.race([closing.then(() => true), delay(5000).then(() => false)]);
if (!detached) {
assert.match(shellStderr, /shutdown complete .*reason=user_quit/, "native service shutdown must finish before fixture cleanup");
assert.match(shellStderr, /resources cleaned; requesting final shell exit/, "native resource cleanup must finish before fixture cleanup");
assert.match(shellStderr, /Waiting for the debugger to disconnect|Debugger ending on/, "only debugger teardown may need fixture cleanup after native shutdown");
terminateFixture();
await closing;
console.log("INFO cleaned up debugger-held fixture after native window close");
}
shell = undefined;
}
async function composer(page) {
const input = page.locator(inputSelector);
await input.waitFor();
await page.waitForFunction(selector => !document.querySelector(selector)?.disabled, inputSelector);
return input;
}
try {
let page = await launch();
await rpc(page, "SetCloseBehavior", "background");
const mainWindow = await shell.browserWindow(page);
await mainWindow.evaluate(win => new Promise((resolve, reject) => {
const timer = setTimeout(() => { clearInterval(poll); reject(new Error("Window did not hide after close")); }, 30000);
const poll = setInterval(() => { if (!win.isVisible()) { clearInterval(poll); clearTimeout(timer); resolve(); } }, 50);
win.close();
}));
check("empty welcome closes to background without a save error");
await mainWindow.evaluate(win => win.show());
// Exercise full shutdown and disk recovery independently of the hide preference.
await rpc(page, "SetCloseBehavior", "quit");
await closeWindow();
check("empty welcome closes through the native window event");
page = await launch();
await page.locator(".sidebar__quick-action:visible").first().click();
let input = await composer(page);
assert.equal(await input.inputValue(), "");
await closeWindow();
check("an empty formal session closes without a save error");
page = await launch();
input = await composer(page);
const tab = (await rpc(page, "ListTabs")).find(item => item.active);
assert.ok(tab?.sessionId);
const ref = { hostId: "local", sessionId: tab.sessionId };
const text = "Keep the latest unsent draft 中文";
await input.fill(text);
// Do not wait for the 250 ms autosave or invoke the renderer flush manually.
await closeWindow();
page = await launch();
input = await composer(page);
assert.equal(await input.inputValue(), text);
assert.equal(JSON.parse((await rpc(page, "GetSessionComposerState", ref)).contentJson).text, text);
check("immediate native close saves the latest text and restores it after restart");
const external = join(home, "external.txt");
writeFileSync(external, "disposable external file reference");
await page.locator(".composer-content-file-input").setInputFiles(external);
// waitForFunction treats an async predicate's Promise as truthy before
// its result is known. Poll awaited RPCs and retain the matching snapshot.
let content;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const candidate = JSON.parse((await rpc(page, "GetSessionComposerState", ref)).contentJson);
if (candidate.workspaceRefs?.some(item => item.path.endsWith("external.txt"))) { content = candidate; break; }
await delay(50);
}
assert.ok(content, "external reference must be saved before restart");
assert.equal(content.text, text, "saving a reference must preserve the existing text");
await closeWindow();
page = await launch();
await composer(page);
assert.deepEqual(JSON.parse((await rpc(page, "GetSessionComposerState", ref)).contentJson), content);
await page.locator(".composer-context__item").first().waitFor();
await closeWindow();
check("external references survive restart and another normal close without losing saved input");
} catch (error) {
report.error = String(error);
if (shell) {
const page = shell.windows()[0];
if (page && !page.isClosed()) writeFileSync(join(home, "failure.txt"), await page.locator("body").innerText().catch(() => "renderer unavailable"));
}
throw error;
} finally {
// Only terminate this isolated fixture after a failed normal-close check.
if (shell) terminateFixture();
writeFileSync(join(home, "results.json"), JSON.stringify(report, null, 2));
console.log(`Evidence: ${join(home, "results.json")}`);
}
+122
View File
@@ -86,6 +86,128 @@ test("only the resource prefixes forward to the loopback origin, query included"
assert.equal(route("reasonix://app/__shell/quit").kind, "notFound");
});
test("attachment uploads forward only the exact upload route, preserving the filename and target", () => {
const query = "?target=chat%2Fsession&name=large%20app.apk";
assert.deepEqual(route(`reasonix://app/__reasonix_attachment_upload${query}`), {
kind: "forward",
target: `/__reasonix_attachment_upload${query}`,
});
for (const path of ["/__reasonix_attachment_upload/", "/__reasonix_attachment_upload/extra", "/__reasonix_attachment_uploadx"]) {
assert.equal(isForwardedPath(path), false, path);
assert.equal(route(`reasonix://app${path}`).kind, "notFound", path);
}
});
test("upload forwarding delivers chunks before the source finishes and preserves cancellation", async () => {
let handler!: (request: Request) => Promise<Response> | Response;
let source!: ReadableStreamDefaultController<Uint8Array>;
const chunks = [new Uint8Array([0x50, 0x4b, 0x03, 0x04]), new Uint8Array([0, 255, 128, 1])];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
source = controller;
controller.enqueue(chunks[0]);
},
});
const controller = new AbortController();
const query = "?target=chat%2Fsession&name=large%20app.apk";
const request = new Request(`reasonix://app/__reasonix_attachment_upload${query}`, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
authorization: "Bearer renderer-controlled",
cookie: "private=value",
origin: "https://untrusted.example",
"x-untrusted": "not-forwarded",
},
body: stream,
signal: controller.signal,
duplex: "half",
} as RequestInit);
// Reading through any whole-body convenience method would recreate the
// large-file allocation that this transport is intended to avoid.
request.arrayBuffer = async () => { throw new Error("upload was buffered with arrayBuffer()"); };
request.text = async () => { throw new Error("upload was buffered with text()"); };
request.blob = async () => { throw new Error("upload was buffered with blob()"); };
let forwarded = false;
registerAppProtocol({
protocol: { handle: (_scheme, callback) => { handler = callback; } },
fetch: async (input, init) => {
forwarded = true;
assert.equal(input, `http://127.0.0.1:12345/__reasonix_attachment_upload${query}`);
assert.equal(init.method, "POST");
assert.equal(init.body, request.body);
assert.equal((init as RequestInit & { duplex?: string }).duplex, "half");
assert.equal(init.signal, request.signal);
assert.equal(init.bypassCustomProtocolHandlers, true);
const headers = new Headers(init.headers);
assert.equal(headers.get("authorization"), "Bearer server-secret");
assert.equal(headers.get("content-type"), "application/octet-stream");
for (const name of ["cookie", "origin", "x-untrusted"]) assert.equal(headers.get(name), null, name);
const reader = (init.body as ReadableStream<Uint8Array>).getReader();
assert.deepEqual(await reader.read(), { done: false, value: chunks[0] });
// The final chunk does not exist until the origin has received the first.
// Aggregating the body before forwarding cannot complete this exchange.
source.enqueue(chunks[1]);
source.close();
assert.deepEqual(await reader.read(), { done: false, value: chunks[1] });
assert.equal((await reader.read()).done, true);
controller.abort();
assert.equal(init.signal?.aborted, true);
return new Response('{"name":"large app.apk"}', { status: 201, headers: { "content-type": "application/json" } });
},
distRoot: dist,
resources: () => ({ origin: "http://127.0.0.1:12345", token: "server-secret" }),
log: { info() {}, warn() {}, error() {} },
});
const response = await handler(request);
assert.equal(forwarded, true, "the upload must reach the resource origin");
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), { name: "large app.apk" });
});
test("upload routes reject methods other than POST and unsupported body formats", async () => {
let handler!: (request: Request) => Promise<Response> | Response;
let forwarded = 0;
registerAppProtocol({
protocol: { handle: (_scheme, callback) => { handler = callback; } },
fetch: async () => { forwarded++; return new Response("unexpected forward"); },
distRoot: dist,
resources: () => ({ origin: "http://127.0.0.1:12345", token: "server-secret" }),
log: { info() {}, warn() {}, error() {} },
});
for (const method of ["GET", "HEAD", "PUT", "PATCH", "DELETE"]) {
const response = await handler(new Request("reasonix://app/__reasonix_attachment_upload?name=large.apk", { method }));
assert.equal(response.status, 405, method);
}
for (const contentType of ["application/json", "multipart/form-data; boundary=test", "text/plain"]) {
const response = await handler(new Request("reasonix://app/__reasonix_attachment_upload?name=large.apk", {
method: "POST", headers: { "content-type": contentType }, body: "not raw bytes",
}));
assert.equal(response.status, 415, contentType);
}
assert.equal(forwarded, 0);
});
test("asset routes cannot be used to forward writes or request bodies", async () => {
let handler!: (request: Request) => Promise<Response> | Response;
let forwarded = 0;
registerAppProtocol({
protocol: { handle: (_scheme, callback) => { handler = callback; } },
fetch: async () => { forwarded++; return new Response("unexpected forward"); },
distRoot: dist,
resources: () => ({ origin: "http://127.0.0.1:12345", token: "server-secret" }),
log: { info() {}, warn() {}, error() {} },
});
for (const path of ["/__reasonix_workspace_media/token/a.png", "/__reasonix_theme_asset/a.png", "/__reasonix_remote_markdown_image?url=https%3A%2F%2Fexample.com%2Fa.png", "/assets/app.js"]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
const response = await handler(new Request(`reasonix://app${path}`, { method, body: "must not reach the origin" }));
assert.equal(response.status, 405, `${method} ${path}`);
}
}
assert.equal(forwarded, 0);
});
test("the dist root honours the override, then packaging layout", () => {
assert.equal(resolveDistRoot({ env: { REASONIX_FRONTEND_DIST: "/tmp/dist" }, appPath: "/app/electron", resourcesPath: "/res", packaged: true }), "/tmp/dist");
assert.equal(resolveDistRoot({ env: {}, appPath: "/app/electron", resourcesPath: "/res", packaged: false }), "/app/frontend/dist");
+15 -3
View File
@@ -15,6 +15,8 @@ export const FORWARDED_PATHS = [
"/__reasonix_remote_markdown_image",
] as const;
const ATTACHMENT_UPLOAD_PATH = "/__reasonix_attachment_upload";
export type AppRoute =
| { kind: "file"; path: string; mime: string }
| { kind: "forward"; target: string }
@@ -52,7 +54,7 @@ export function mimeFor(path: string): string {
}
export function isForwardedPath(pathname: string): boolean {
return FORWARDED_PATHS.some((prefix) =>
return pathname === ATTACHMENT_UPLOAD_PATH || FORWARDED_PATHS.some((prefix) =>
prefix.endsWith("/") ? pathname.startsWith(prefix) : pathname === prefix || pathname.startsWith(prefix + "/"));
}
@@ -125,6 +127,11 @@ export function registerAppProtocol(deps: AppProtocolDeps): void {
deps.log.warn(`404 ${request.url}: ${route.reason}`);
return text(404, "Not found");
}
const upload = new URL(request.url).pathname === ATTACHMENT_UPLOAD_PATH;
if (upload ? request.method !== "POST" : request.method !== "GET" && request.method !== "HEAD") {
return text(405, "Method not allowed");
}
if (upload && request.headers.get("content-type") !== "application/octet-stream") return text(415, "Unsupported upload type");
if (route.kind === "forward") {
const resources = deps.resources();
if (!resources) return text(503, "Desktop service unavailable");
@@ -133,10 +140,15 @@ export function registerAppProtocol(deps: AppProtocolDeps): void {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
if (upload) headers.set("content-type", "application/octet-stream");
try {
return await deps.fetch(resources.origin + route.target, { method: request.method, headers, bypassCustomProtocolHandlers: true });
const init: RequestInit & { bypassCustomProtocolHandlers?: boolean; duplex?: "half" } = {
method: request.method, headers, signal: request.signal, bypassCustomProtocolHandlers: true,
...(upload ? { body: request.body, duplex: "half" as const } : {}),
};
return await deps.fetch(resources.origin + route.target, init);
} catch (error) {
deps.log.warn(`resource forward failed for ${route.target}: ${errorText(error)}`);
deps.log.warn(`resource forward failed for ${new URL(request.url).pathname}: ${errorText(error)}`);
return text(502, "Resource origin unreachable");
}
}
+12
View File
@@ -25,6 +25,18 @@ function harness(limit?: number) {
return { client, written, requests, notifications, errors, feed, lastFrame };
}
test("oversized outgoing requests never reach or disconnect the service", async () => {
const h = harness(256);
await assert.rejects(h.client.request("desktop/invoke", { data: "a".repeat(257) }), OversizeFrameError);
assert.equal(h.written.length, 0);
assert.equal(h.client.pendingCount, 0);
assert.equal(h.client.closed, false);
const pending = h.client.request("ping", {});
const sent = h.lastFrame();
h.feed(JSON.stringify({ jsonrpc: "2.0", id: sent.id, result: "pong" }) + "\n");
assert.equal(await pending, "pong");
});
test("LineDecoder reassembles partial lines and strips CR", () => {
const decoder = new LineDecoder();
assert.deepEqual(decoder.push(Buffer.from('{"a":')), []);
+10 -3
View File
@@ -82,7 +82,7 @@ export class RpcClient {
private closedWith: Error | null = null;
readonly stats = { ignoredLines: 0, orphanResponses: 0 };
constructor(private readonly transport: RpcTransport, private readonly handlers: RpcHandlers, limit = MAX_FRAME_BYTES) {
constructor(private readonly transport: RpcTransport, private readonly handlers: RpcHandlers, private readonly limit = MAX_FRAME_BYTES) {
this.decoder = new LineDecoder(limit);
}
@@ -119,7 +119,7 @@ export class RpcClient {
: null;
this.pending.set(id, { resolve, reject, timer });
try {
this.transport.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
this.writeFrame({ jsonrpc: "2.0", id, method, params });
} catch (error) {
this.pending.delete(id);
if (timer) clearTimeout(timer);
@@ -130,7 +130,14 @@ export class RpcClient {
notify(method: string, params: unknown): void {
if (this.closedWith) return;
this.transport.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
this.writeFrame({ jsonrpc: "2.0", method, params });
}
private writeFrame(frame: Record<string, unknown>): void {
const line = JSON.stringify(frame) + "\n";
const bytes = Buffer.byteLength(line);
if (bytes > this.limit) throw new OversizeFrameError(bytes, this.limit);
this.transport.write(line);
}
close(error: Error): void {
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"regexp"
"strings"
"reasonix/internal/control"
)
func rewriteDraftExternalFileRef(input, path, token string) string {
old := "@" + control.EscapeRefPath(path)
pattern := regexp.MustCompile(regexp.QuoteMeta(old) + `($|\s)`)
return pattern.ReplaceAllStringFunc(input, func(match string) string {
return "@" + control.EscapeRefPath(token) + strings.TrimPrefix(match, old)
})
}
+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
@@ -19,6 +19,7 @@ function Fixture() {
</nav>
{[
["模型连接", "dial tcp: connection refused"],
["未生成答复", "no_deliverable_output: model finished without a visible final answer after recovery; this turn did not complete"],
["服务限流", "HTTP 429: too many requests"],
["历史会话", "读取会话失败:unrecognized storage record revision"],
["文件操作", "open /tmp/数据.txt: permission denied"],
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>Remote hydration regression</title></head><body><div id="root"></div><script type="module" src="/bench/session-switch-remote.tsx"></script></body></html>
@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import { installDesktopHostStub } from "../src/__tests__/desktopHostStub";
import { useRemoteSession } from "../src/lib/useRemoteSession";
import type { TranscriptFollowResponse } from "../src/generated/desktopContract.generated";
let revision = 1, reads = 0;
let publishReads = (_reads: number) => {};
const pending: Array<{ revision: number; resolve: (value: TranscriptFollowResponse) => void }> = [];
const status = { running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: false };
const stub = installDesktopHostStub({
RemoteTabHydrationState: async () => ({ state: "ready", hydrationRevision: revision }),
RemoteTabStatus: async () => status, RemoteTabMetadata: async () => ({ status }),
RemoteTabSnapshot: async () => ({ status, history: [] }),
ForkTargetsRemoteTab: async () => ({ targets: [], verifiable: false }),
RemoteTranscriptFollowForTab: async (_id, request) => {
if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false };
if (request.subscription) return new Promise(() => {});
publishReads(++reads);
return new Promise(resolve => pending.push({ revision, resolve }));
},
});
function deliver(entry: typeof pending[number] | undefined) {
if (!entry) return;
const id = `session-${entry.revision}`;
entry.resolve({ protocolVersion: 2, subscription: id, changes: [], resetRequired: false,
snapshot: { protocolVersion: 1, snapshotId: id, identity: { sessionId: id, runtimeEpoch: id, rewriteEpoch: 0, headId: "" },
projectionRevision: 1, coveredThroughSeq: 1, durableSeq: 1, records: [], activeRecords: [], activeAttempts: [],
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false,
totalRecords: 0, totalTurns: 0, stale: false },
history: { status: "ready", snapshotSequence: 1, coverageSequence: 1, generation: id, totalTurns: 0,
hasOlder: false, hasNewer: false, messages: [] } });
}
function Fixture() {
const session = useRemoteSession("remote-browser", "ready", "remote-session");
const [count, setCount] = useState(0);
publishReads = setCount;
const ready = () => stub.emit("remote-tab:remote-browser:state", { state: "ready", hydrationRevision: revision });
return <main style={{ fontFamily: "sans-serif", padding: 40 }}>
<h1>Remote session hydration</h1>
<button onClick={ready}>Replay ready</button>
<button onClick={() => { revision++; ready(); }}>New generation</button>
<button onClick={() => {
stub.emitServiceState({ phase: "stopping", generation: "test-service" });
revision = 1;
stub.emitServiceState({ phase: "ready", generation: "replacement-service" });
stub.emit("desktop:resync", {});
ready();
}}>Restart service</button>
<button onClick={() => deliver(pending.pop())}>Deliver latest</button>
<button onClick={() => deliver(pending.shift())}>Deliver oldest</button>
<output data-reads={count} data-hydrated={session.hydrated} data-session={session.transcript.transcriptSessionId}>
{JSON.stringify({ reads: count, hydrated: session.hydrated, session: session.transcript.transcriptSessionId })}
</output>
</main>;
}
createRoot(document.getElementById("root")!).render(<Fixture />);
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><title>Session navigation regression</title></head>
<body><div id="root"></div><script type="module" src="/bench/session-switch-sidebar.tsx"></script></body></html>
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { createServer } from "vite";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(root, ".pw-browsers");
const { chromium } = await import("playwright");
const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 0 } });
await server.listen();
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const errors = [];
page.on("pageerror", error => errors.push(error.message));
page.on("console", event => { if (["warning", "error"].includes(event.type())) errors.push(event.text()); });
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/bench/session-switch-sidebar.html`);
await page.getByRole("button", { name: "Open session", exact: true }).waitFor();
for (let cycle = 0; cycle < 40; cycle++) {
await page.getByRole("button", { name: "Refresh list", exact: true }).click();
assert.deepEqual(await page.locator("li").evaluateAll(rows => rows.map(row => row.dataset.session)),
cycle % 2 ? ["other", "reviewed", "original"] : ["original", "reviewed"]);
}
await page.getByRole("button", { name: "Archive original", exact: true }).click();
assert.equal(await page.locator('li[data-session="original"]').count(), 0);
assert.equal(await page.locator('li[data-session="reviewed"]').count(), 1);
await page.getByRole("button", { name: "Refresh sibling", exact: true }).click();
assert.equal(await page.locator('li[data-session="original"]').count(), 0);
await page.getByRole("button", { name: "Restore original", exact: true }).click();
assert.equal(await page.locator('li[data-session="original"]').count(), 1);
const samples = [];
for (let run = 0; run < 5; run++) {
await page.getByRole("button", { name: "Open session", exact: true }).click();
await page.locator('output[data-status="opening"]').waitFor();
// Keep publication pending across event turns, as real host IPC does.
await page.getByRole("button", { name: "Refresh list", exact: true }).click();
assert.equal(await page.locator("output").getAttribute("data-premature"), "0");
await page.getByRole("button", { name: "Commit binding", exact: true }).click();
await page.getByRole("button", { name: "Deliver baseline", exact: true }).click();
await page.locator('output[data-status="ready"]').waitFor();
await page.getByRole("button", { name: "Replay notifications", exact: true }).click();
await page.getByRole("button", { name: "Refresh list", exact: true }).click();
assert.equal(await page.locator("output").getAttribute("data-reads"), "1");
assert.equal(await page.locator("output").getAttribute("data-premature"), "0");
samples.push(JSON.parse(await page.locator("output").textContent()));
}
assert.deepEqual(errors, [], "browser console is clean");
const evidence = process.env.REASONIX_SWITCH_EVIDENCE ?? "/tmp/reasonix-session-switch-sidebar";
await mkdir(evidence, { recursive: true });
await page.screenshot({ path: path.join(evidence, "verified.png") });
await writeFile(path.join(evidence, "results.json"), JSON.stringify({ samples, errors, catalogRefreshes: 50 }, null, 2));
console.log(`PASS 50 catalog refreshes, 5 canonical opens, one baseline/open, no premature reads; evidence: ${evidence}`);
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/bench/session-switch-remote.html`);
await page.locator('output[data-reads="1"]').waitFor();
for (let i = 0; i < 5; i++) await page.getByRole("button", { name: "Replay ready", exact: true }).click();
assert.equal(await page.locator("output").getAttribute("data-reads"), "1");
await page.getByRole("button", { name: "Deliver latest", exact: true }).click();
await page.locator('output[data-hydrated="true"][data-session="session-1"]').waitFor();
await page.getByRole("button", { name: "Replay ready", exact: true }).click();
assert.equal(await page.locator("output").getAttribute("data-reads"), "1");
await page.getByRole("button", { name: "New generation", exact: true }).click();
await page.locator('output[data-reads="2"]').waitFor();
await page.getByRole("button", { name: "New generation", exact: true }).click();
await page.locator('output[data-reads="3"]').waitFor();
await page.getByRole("button", { name: "Deliver latest", exact: true }).click();
await page.locator('output[data-hydrated="true"][data-session="session-3"]').waitFor();
await page.getByRole("button", { name: "Deliver oldest", exact: true }).click();
await page.getByRole("button", { name: "Replay ready", exact: true }).click();
assert.equal(await page.locator("output").getAttribute("data-session"), "session-3");
assert.equal(await page.locator("output").getAttribute("data-reads"), "3");
await page.getByRole("button", { name: "Restart service", exact: true }).click();
await page.locator('output[data-reads="4"]').waitFor();
await page.getByRole("button", { name: "Deliver latest", exact: true }).click();
await page.locator('output[data-hydrated="true"][data-session="session-1"]').waitFor();
await page.getByRole("button", { name: "Replay ready", exact: true }).click();
assert.equal(await page.locator("output").getAttribute("data-reads"), "4");
assert.deepEqual(errors, [], "remote browser console is clean");
await page.screenshot({ path: path.join(evidence, "remote-verified.png") });
await writeFile(path.join(evidence, "remote-results.json"), JSON.stringify({ reads: 4, revisions: 3, duplicateReady: 8, serviceRestarts: 1, staleCutIgnored: true, errors }, null, 2));
console.log("PASS remote duplicate ready, genuine resets and stale baseline completion in Chromium");
} finally {
await browser?.close();
await server.close();
}
@@ -0,0 +1,98 @@
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import { useController } from "../src/lib/useController";
import { projectSessionRowKey } from "../src/lib/projectSessionIdentity";
import { mergeProjectTopicPage, projectTreeWithoutTopics } from "../src/lib/projectTreeTopic";
import { createSessionLifecycleFences } from "../src/lib/sessionLifecycleFences";
import { installDesktopHostStub } from "../src/__tests__/desktopHostStub";
import { meta, tabMeta } from "../src/__tests__/helpers/sessionSwitchFixtures";
import type { ProjectNode } from "../src/lib/types";
import type { FollowRequest, TranscriptFollowResponse } from "../src/generated/desktopContract.generated";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>(done => { resolve = done; });
return { promise, resolve };
}
let current = "initial", epoch = "initial-epoch", committed = true;
let releaseOpen = deferred(), releaseBaseline = deferred();
let reads = 0, premature = 0;
const metadata = () => meta({ session: { hostId: "local", sessionId: current }, sessionPath: `session-id:${current}`,
sessionGeneration: 1, runtime: { phase: "ready", epoch } });
const stub = installDesktopHostStub({
RegisterNavigationIntent: async () => {},
ListTabs: async () => [tabMeta({ ...metadata(), active: true })], MetaForTab: async () => metadata(),
ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {},
ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
EffortForTab: async () => ({ supported: false, current: "auto", levels: [] }),
BalanceForTab: async () => ({ available: false, display: "" }), JobsForTab: async () => [],
CheckpointsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
OpenSession: async (ref: { sessionId: string }) => {
current = ref.sessionId; epoch = `${current}-epoch`; committed = false;
stub.emit("runtime:rebuilt", "tab-a", epoch);
stub.emit("runtime:rebuilt", "tab-a", epoch);
stub.emit("agent:ready", "tab-a");
await releaseOpen.promise;
committed = true;
},
TranscriptFollowForTab: async (_tab: string, request: FollowRequest): Promise<TranscriptFollowResponse> => {
if (request.close) return { protocolVersion: 2, subscription: request.subscription, changes: [], resetRequired: false };
if (request.subscription) return new Promise(() => {});
const id = current;
if (id !== "initial") reads++;
if (!committed) { premature++; throw new Error("runtime changed while following transcript"); }
if (id !== "initial") await releaseBaseline.promise;
return { protocolVersion: 2, subscription: `${id}-${reads}`, changes: [], resetRequired: false,
snapshot: { protocolVersion: 1, snapshotId: id, identity: { sessionId: id, runtimeEpoch: epoch, rewriteEpoch: 0, headId: "" },
projectionRevision: 1, coveredThroughSeq: 1, durableSeq: 1, records: [], activeRecords: [], activeAttempts: [],
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 },
before: 0, hasOlder: false, totalRecords: 0, totalTurns: 0, stale: false },
history: { status: "ready", snapshotSequence: 1, coverageSequence: 1, generation: id, totalTurns: 0,
hasOlder: false, hasNewer: false, messages: [] } };
},
});
const source: ProjectNode = { key: "legacy", kind: "global_topic", label: "Imported history",
source: { hostId: "local", sourceKey: "browser-source", path: "/history.jsonl" } };
const original: ProjectNode = { key: "original", kind: "global_topic", label: "Imported history",
session: { hostId: "local", sessionId: "original" }, identityAliases: ["source\0local\0browser-source"] };
const reviewed: ProjectNode = { ...original, key: "reviewed", session: { hostId: "local", sessionId: "reviewed" } };
const other: ProjectNode = { key: "other", kind: "global_topic", label: "Other session", session: { hostId: "local", sessionId: "other" } };
const fences = createSessionLifecycleFences();
function Fixture() {
const controller = useController();
const [cycle, setCycle] = useState(-1);
const [status, setStatus] = useState("idle");
const [result, setResult] = useState({ reads: 0, premature: 0, elapsed: 0 });
const pages = cycle < 0 ? [source] : cycle % 2
? mergeProjectTopicPage([other, reviewed], [original], true)
: mergeProjectTopicPage([original], [reviewed], true);
const rows = projectTreeWithoutTopics(pages, fences.keys());
return <main style={{ fontFamily: "system-ui", padding: 32 }}>
<h1>Session navigation and sidebar regression</h1>
<p>Shared-source sessions remain separate through repeated catalog refreshes.</p>
<button onClick={() => setCycle(value => value + 1)}>Refresh list</button>
<button onClick={() => { fences.archive(original, { lifecycleGeneration: 1, operationId: "archive-original" }); setCycle(value => value + 2); }}>Archive original</button>
<button onClick={() => { fences.observeDirectory([{ ...reviewed, lifecycleGeneration: 99 }]); setCycle(value => value + 2); }}>Refresh sibling</button>
<button onClick={() => { fences.observeDirectory([{ ...original, lifecycleGeneration: 2 }]); setCycle(value => value + 2); }}>Restore original</button>
<ul>{rows.map(row => <li key={projectSessionRowKey(row)} data-session={row.session?.sessionId ?? "source"}>{row.label}</li>)}</ul>
<button disabled={status === "opening" || !controller.state.meta?.ready} onClick={() => {
const startedAt = performance.now();
reads = 0; premature = 0; releaseOpen = deferred(); releaseBaseline = deferred(); setStatus("opening");
const intent = controller.noteNavigationIntent();
void controller.openCanonicalSession({ hostId: "local", sessionId: `target-${intent}` }, intent).then(() => {
setResult({ reads, premature, elapsed: performance.now() - startedAt }); setStatus("ready");
}, () => setStatus("failed"));
}}>Open session</button>
<button onClick={() => releaseOpen.resolve()}>Commit binding</button>
<button onClick={() => releaseBaseline.resolve()}>Deliver baseline</button>
<button onClick={() => {
stub.emit("runtime:rebuilt", "tab-a", epoch); stub.emit("agent:ready", "tab-a");
setResult(value => ({ ...value, reads, premature }));
}}>Replay notifications</button>
<output data-status={status} data-reads={reads} data-premature={premature} data-cycle={cycle}>
{JSON.stringify({ status, ...result })}
</output>
</main>;
}
createRoot(document.getElementById("root")!).render(<Fixture />);
+8 -7
View File
@@ -32,9 +32,10 @@
"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:session-switch-regression": "tsx src/__tests__/session-alias-isolation.test.ts && tsx src/__tests__/session-lifecycle-fences.test.ts && tsx src/__tests__/project-session-row-identity.test.tsx && tsx src/__tests__/canonical-navigation-hydration.test.tsx && tsx src/__tests__/remote-hydration-owner.test.ts && tsx src/__tests__/remote-hydration-ownership.test.tsx",
"test:session-loading-browser": "pnpm test:session-switch-regression && node bench/session-loading.mjs && node bench/session-switch-sidebar.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",
"test:maintenance-browser": "node bench/maintenance-submit.mjs",
"test:turn-outline-browser": "node bench/turn-outline.mjs",
@@ -52,7 +53,7 @@
"test:popover-browser": "node bench/anchored-popover-lifecycle.mjs",
"test:popover-electron": "node bench/anchored-popover-lifecycle.mjs --electron",
"test": "node scripts/test-todo-visibility.mjs && node scripts/run-tests.mjs",
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/remote-connect-wizard.test.tsx && tsx src/__tests__/add-project-entries.test.ts && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx && tsx src/__tests__/remote-running-reconcile.test.ts && tsx src/__tests__/remote-project-tree.test.tsx && tsx src/__tests__/remote-session-cache.test.ts && tsx src/__tests__/remote-tab-opened.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/remote-connect-wizard.test.tsx && tsx src/__tests__/add-project-entries.test.ts && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx && tsx src/__tests__/remote-running-reconcile.test.ts && tsx src/__tests__/remote-session-read-activity.test.tsx && tsx src/__tests__/remote-session-badges.test.tsx && tsx src/__tests__/remote-project-tree.test.tsx && tsx src/__tests__/remote-session-cache.test.ts && tsx src/__tests__/remote-tab-opened.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
"test:bench": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/run.mjs",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
@@ -72,14 +73,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 +104,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 +114,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"
},
@@ -314,7 +314,10 @@ for (const path of localeChunks) {
// 64606 / 65349 B, so both dialect ceilings ratchet to the next tenth.
// Model-application copy on the read-pause base measures 64734 / 65499 B,
// adding 128 / 150 B. Retain only the next one-decimal ceiling.
const budget = name.startsWith("zh-TW-") ? 64.0 * 1024 : 63.3 * 1024;
// The localized Studio introduction and the provider-failure causes bring zh
// to 64826 B and zh-TW to 65714 B measured. Each ceiling keeps about 100 B of
// cross-platform headroom; the compressed initial budgets stay fixed.
const budget = name.startsWith("zh-TW-") ? 64.3 * 1024 : 63.4 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
@@ -561,6 +564,14 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// Ready-event cold-history joining adds 163 B locally (2106326 B versus
// 2106163 B); retain the producer margin and round to the smallest 2057.2 KiB
// ceiling that covers the same Linux build. Other bundle budgets stay fixed.
const rawInitialBudgetKiB = 2_057.2;
// The Studio introduction and the provider-failure causes measure 2107767 B in
// the initial raw boundary; the ceiling keeps 341 B of producer headroom, and
// compressed initial JS, per-chunk and CSS ceilings remain unchanged.
// Session navigation, alias ownership and remote ready barriers add 1810 B
// (0.086%) over isolated main-v2 8cc804e687 with the same toolchain:
// 2109672 B versus 2107862 B, including 311 B for service-restart recovery.
// Keep the 186 B producer margin at the smallest one-decimal ceiling; gzip,
// chunk, CSS, locale, and resident-history limits remain unchanged.
const rawInitialBudgetKiB = 2_060.5;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+2
View File
@@ -50,6 +50,8 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"remote-session-surface.test.tsx": "test:remote (needs the svg stub register)",
"remote-running-reconcile.test.ts": "test:remote",
"remote-project-tree.test.tsx": "test:remote",
"remote-session-read-activity.test.tsx": "test:remote",
"remote-session-badges.test.tsx": "test:remote",
"statusbar-workspace.test.tsx": "test:remote",
"updater-shared-state.test.tsx": "test:updater",
"window-state-ordering.test.ts": "test:window-state",
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { ordinaryFileSource } from "../lib/attachDedup";
import { formatWorkspaceReference, parseWorkspaceReference } from "../lib/workspaceDrag";
import { isExternalWorkspaceReference, restoreExternalFolderReferences } from "../lib/attachmentSubmit";
import type { AppBindings } from "../lib/bridge";
const a = new File(["first"], "app.apk", { lastModified: 1 });
const b = new File(["other"], "app.apk", { lastModified: 1 });
assert.equal(ordinaryFileSource(a, ""), ordinaryFileSource(a, ""));
assert.notEqual(ordinaryFileSource(a, ""), ordinaryFileSource(b, ""));
assert.equal(ordinaryFileSource(a, "/outside/app.apk"), "path:/outside/app.apk");
for (const path of ["/outside/large app.apk", "C:\\Downloads\\large app.apk", "src/main.go"]) {
assert.equal(parseWorkspaceReference(formatWorkspaceReference(path))?.path, path);
}
assert.equal(isExternalWorkspaceReference({ path: "/outside/large app.apk", displayPath: "/outside/large app.apk" }), true);
assert.equal(isExternalWorkspaceReference({ path: "C:/Downloads/large app.apk", displayPath: "C:/Downloads/large app.apk" }), true);
assert.equal(isExternalWorkspaceReference({ path: "src/main.go" }), false);
const calls: string[] = [];
const bindings: Partial<AppBindings> = {
CaptureAttachmentTarget: async () => ({ token: "captured", capabilities: ["attachments-v2"] }),
AttachDroppedForTarget: async (token: string, path: string) => { calls.push(`${token}:${path}`); return { kind: "workspace", path, displayPath: path }; },
ReleaseAttachmentTarget: async (token: string) => { calls.push(`release:${token}`); },
};
await restoreExternalFolderReferences(bindings as AppBindings, { kind: "session", tabId: "source" }, [{ path: "/outside/large app.apk", displayPath: "/outside/large app.apk" }]);
assert.deepEqual(calls, ["captured:/outside/large app.apk", "release:captured"]);
console.log("attachment references: object identity, escaped paths and restored exact-file authorization passed");
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { ToastProvider, useToast, type ToastContextValue } from "../lib/toast";
import { LocaleProvider } from "../lib/i18n";
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost", pretendToBeVisual: true });
Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
const root = createRoot(document.getElementById("root")!);
let toast!: ToastContextValue;
function Fixture() { toast = useToast(); return null; }
try {
await act(async () => root.render(<LocaleProvider><ToastProvider><Fixture /></ToastProvider></LocaleProvider>));
// The filename starts with ASCII; generic exception classification must not
// hide this already-localized, typed upload refusal behind a details toggle.
const message = "oversize.apk 过大,无法附加(上限 1024 MB)";
await act(async () => { toast.showToast(message, "warn", { summary: message }); });
assert.equal(document.querySelector(".toast--warn .user-error__summary")?.textContent, message);
assert.equal(document.querySelector(".toast--warn .user-error__toggle"), null);
await act(async () => root.unmount());
console.log("attachment ceiling toast: localized filename and limit remain visible");
} finally { dom.window.close(); }
@@ -29,6 +29,9 @@ try {
assert.deepEqual(captures[0],target);
assert.equal(prepared.structured.input,"describe","recovery adds no provider prompt bytes");
const external = [{path:restoredPath,isDir:true,displayPath:"/external/folder"}];
await assert.rejects(restoreExternalFolderReferences({ ...app,
CaptureAttachmentTarget: async () => { throw Error("unsupported: attachments-v2"); },
}, target, external), /unsupported: attachments-v2/, "submission must still validate attachment capability");
await restoreExternalFolderReferences(app,target,external);
restoredPath="__reasonix_external_folder/changed/folder";
await assert.rejects(restoreExternalFolderReferences(app,target,external),/folder changed/);
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { attachOrdinaryFile, attachmentUploadFailure } from "../lib/attachmentSubmit";
import type { AppBindings } from "../lib/bridge";
const limit = { code: "attachment.too_large", message: "attachment exceeds 4 byte stream limit (user)", limit_bytes: 4, limit_source: "user" };
await assert.rejects(
attachOrdinaryFile({} as AppBindings, "captured", new File(["12345"], "app.apk"), undefined,
(async () => new Response(JSON.stringify(limit), { status: 413, headers: { "content-type": "application/json" } })) as typeof fetch),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(error.message, limit.message);
assert.equal((error as Error & typeof limit).code, limit.code);
assert.equal((error as Error & typeof limit).limit_bytes, 4);
assert.equal((error as Error & typeof limit).limit_source, "user");
assert.equal(attachmentUploadFailure(error, "generic failure"), limit.message);
assert.equal(attachmentUploadFailure(error, "generic failure", bytes => `limit ${bytes}`), "limit 4");
return true;
},
);
assert.equal(attachmentUploadFailure(new Error("unclassified server error"), "generic failure"), "generic failure");
await assert.rejects(
attachOrdinaryFile({} as AppBindings, "captured", new File(["12345"], "app.apk"), undefined,
(async () => new Response("upstream unavailable", { status: 502 })) as typeof fetch),
/File upload failed \(502\)/,
);
console.log("attachment stream limit: typed server errors retain ceiling and source");
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { installRemoteSurfaceDom } from "./helpers/remoteSurfaceDom";
import { installDesktopHostStub } from "./desktopHostStub";
import { meta, tabMeta } from "./helpers/sessionSwitchFixtures";
import { useController } from "../lib/useController";
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
}
const dom = installRemoteSurfaceDom();
let current = "first";
let epoch = "first-epoch";
const reads: string[] = [];
let opened = deferred<void>();
let releaseOpen = deferred<void>();
let baseline = deferred<void>();
let bindingCommitted = true;
let controller!: ReturnType<typeof useController>;
const currentMeta = () => meta({ session: { hostId: "local", sessionId: current }, sessionPath: `session-id:${current}`,
sessionGeneration: 1, runtime: { phase: "ready", epoch } });
const stub = installDesktopHostStub({
RegisterNavigationIntent: async () => {},
ListTabs: async () => [tabMeta({ ...currentMeta(), active: true })],
MetaForTab: async () => currentMeta(),
ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {},
ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
EffortForTab: async () => ({ supported: false, current: "auto", levels: [] }),
BalanceForTab: async () => ({ available: false, display: "" }), JobsForTab: async () => [],
CheckpointsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
OpenSession: async (ref: { sessionId: string }) => {
bindingCommitted = false;
current = ref.sessionId; epoch = `${current}-epoch`;
stub.emit("runtime:rebuilt", "tab-a", epoch);
stub.emit("runtime:rebuilt", "tab-a", epoch);
stub.emit("agent:ready", "tab-a");
opened.resolve();
await releaseOpen.promise;
bindingCommitted = true;
},
TranscriptFollowForTab: async (_tab: string, request: FollowRequest): Promise<TranscriptFollowResponse> => {
if (request.close) return { protocolVersion: 2, subscription: request.subscription, changes: [], resetRequired: false };
if (request.subscription) return new Promise(() => {});
const id = current, boundEpoch = epoch;
reads.push(id);
if (!bindingCommitted) throw new Error("runtime changed while following transcript");
if (id !== "first") await baseline.promise;
return { protocolVersion: 2, subscription: `${id}-${reads.length}`, changes: [], resetRequired: false,
snapshot: { protocolVersion: 1, snapshotId: id,
identity: { sessionId: id, runtimeEpoch: boundEpoch, rewriteEpoch: 0, headId: "" },
projectionRevision: 1, coveredThroughSeq: 1, durableSeq: 1, records: [], activeRecords: [], activeAttempts: [],
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 },
before: 0, hasOlder: false, totalRecords: 0, totalTurns: 0, stale: false },
history: { status: "ready", snapshotSequence: 1, coverageSequence: 1, generation: id,
totalTurns: 0, hasOlder: false, hasNewer: false, messages: [] } };
},
});
const root = createRoot(document.getElementById("root")!);
function Probe() { controller = useController(); return null; }
async function drain() { await act(async () => { for (let i = 0; i < 40; i++) await new Promise(resolve => setImmediate(resolve)); }); }
try {
await act(async () => root.render(<Probe />));
await drain();
assert.ok(reads.includes("first"), "startup is hydrated");
reads.length = 0;
let navigation!: Promise<void>;
await act(async () => {
const intent = controller.noteNavigationIntent();
navigation = controller.openCanonicalSession({ hostId: "local", sessionId: "second" }, intent);
await opened.promise;
});
await drain();
act(() => releaseOpen.resolve());
await drain();
await act(async () => { baseline.resolve(); await navigation; });
await drain();
assert.equal(reads.filter(id => id === "second").length, 1,
"ready/rebuilt notifications and explicit navigation share one target baseline");
assert.equal(controller.state.meta?.session?.sessionId, "second");
assert.equal(controller.state.hydrateError, undefined);
await act(async () => {
stub.emit("runtime:rebuilt", "tab-a", epoch);
stub.emit("agent:ready", "tab-a");
});
await drain();
assert.equal(reads.length, 1, "late notifications for the installed runtime do not reload its baseline");
epoch = "second-replacement-epoch";
await act(async () => stub.emit("runtime:rebuilt", "tab-a", epoch));
await drain();
assert.equal(reads.length, 2, "an actual runtime replacement still establishes a new subscription");
const slowOpened = deferred<void>(), slowReply = deferred<void>();
const lastOpened = deferred<void>(), lastReply = deferred<void>();
stub.commands.OpenSession = async (ref: { sessionId: string }) => {
if (ref.sessionId === "superseded") {
slowOpened.resolve(); await slowReply.promise; return;
}
current = ref.sessionId; epoch = `${current}-epoch`; bindingCommitted = false;
stub.emit("runtime:rebuilt", "tab-a", epoch);
stub.emit("agent:ready", "tab-a");
lastOpened.resolve(); await lastReply.promise; bindingCommitted = true;
};
let slow!: Promise<void>, last!: Promise<void>;
await act(async () => {
slow = controller.openCanonicalSession({ hostId: "local", sessionId: "superseded" }, controller.noteNavigationIntent());
await slowOpened.promise;
});
await act(async () => {
last = controller.openCanonicalSession({ hostId: "local", sessionId: "last" }, controller.noteNavigationIntent());
await lastOpened.promise;
});
await act(async () => { slowReply.resolve(); await slow; stub.emit("agent:ready", "tab-a"); });
await drain();
assert.equal(reads.filter(id => id === "last").length, 0, "stale finally cannot release the newer navigation's hydration ownership");
await act(async () => { lastReply.resolve(); await last; });
await drain();
assert.equal(reads.filter(id => id === "last").length, 1);
assert.equal(controller.state.meta?.session?.sessionId, "last", "last canonical selection wins");
stub.commands.OpenSession = async () => { throw new Error("session not found"); };
await act(async () => {
await assert.rejects(controller.openCanonicalSession({ hostId: "local", sessionId: "missing" }, controller.noteNavigationIntent()), /session not found/);
});
epoch = "after-failed-open";
const beforeRecovery = reads.length;
await act(async () => { stub.emit("runtime:rebuilt", "tab-a", epoch); });
await drain();
assert.equal(reads.length, beforeRecovery + 1, "failed navigation releases ownership so later runtime recovery works");
assert.equal(controller.state.meta?.session?.sessionId, "last", "failed target never replaces the source identity");
} finally {
baseline.resolve(); releaseOpen.resolve();
await act(async () => root.unmount());
stub.uninstall(); dom.window.close();
}
console.log("PASS canonical navigation performs one target baseline");
@@ -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");
@@ -11,6 +11,7 @@ import type { AppBindings } from "../lib/bridge";
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode } from "../lib/types";
import { dispatchNativeFileDrop, installDesktopHostStub, type DesktopHostStubOptions } from "./desktopHostStub";
import { createFileUploadMock, dispatchPasteFile } from "./fileUploadStub";
let passed = 0;
let failed = 0;
@@ -165,19 +166,7 @@ function mockApp(methods: Partial<AppBindings>, stubOptions?: DesktopHostStubOpt
}).main.App, stubOptions ?? { getPathForFile: (file) => file.name });
}
function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [file],
items: [],
types: ["Files"],
getData: () => "",
},
});
textarea.dispatchEvent(event);
}
const mockFileUpload = createFileUploadMock("goal-attachment-target", { ok, eq });
function dispatchPasteText(input: HTMLElement, text: string) {
const event = new Event("paste", { bubbles: true, cancelable: true });
@@ -394,9 +383,8 @@ console.log("\ncomposer goal toggle");
{
// Attachment-only first Goal: no text, no skill — attachment refs are valid task context.
const dom = installDom();
mockApp({
SavePastedFileForTarget: async () => ".reasonix/attachments/notes.txt",
});
mockApp({}, { getPathForFile: () => "" });
const restoreUpload = mockFileUpload(async () => ".reasonix/attachments/notes.txt");
const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render for attachment-only goal");
@@ -423,6 +411,7 @@ console.log("\ncomposer goal toggle");
root.unmount();
});
dom.window.close();
restoreUpload();
}
{
@@ -750,10 +739,9 @@ console.log("\ncomposer goal toggle");
{
const dom = installDom();
mockApp({
SavePastedFileForTarget: async () => {
throw new Error("/Users/example/private.pdf: permission denied");
},
mockApp({}, { getPathForFile: () => "" });
const restoreUpload = mockFileUpload(async () => {
throw new Error("/Users/example/private.pdf: permission denied");
});
const { root, rerender } = await renderComposer();
await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
@@ -769,7 +757,7 @@ console.log("\ncomposer goal toggle");
});
await waitFor("pasted file failure toast", () => document.body.textContent?.includes("File attach failed") === true);
ok(document.body.textContent?.includes("File attach failed") === true, "SavePastedFile rejection shows a visible error");
ok(document.body.textContent?.includes("File attach failed") === true, "raw upload rejection shows a visible error");
eq(textarea.value, "keep this draft", "failed pasted file attach preserves composer text");
ok(sendButton.disabled === false, "failed pasted file attach clears the pending state");
ok(document.body.textContent?.includes("/Users/example") === false, "pasted file failure toast does not expose the local path");
@@ -778,13 +766,13 @@ console.log("\ncomposer goal toggle");
root.unmount();
});
dom.window.close();
restoreUpload();
}
{
const dom = installDom();
mockApp({
SavePastedFileForTarget: async () => ".reasonix/attachments/notes.txt",
});
mockApp({}, { getPathForFile: () => "" });
const restoreUpload = mockFileUpload(async () => ".reasonix/attachments/notes.txt");
const { root } = await renderComposer();
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
@@ -802,6 +790,7 @@ console.log("\ncomposer goal toggle");
root.unmount();
});
dom.window.close();
restoreUpload();
}
{
@@ -894,7 +883,7 @@ console.log("\ncomposer goal toggle");
await flushTimers();
});
eq(calls.send.join(","), "inspect @/Users/example/Folder With Spaces/", "external folder display text uses the real folder path");
eq(calls.send.join(","), "inspect @/Users/example/Folder\\ With\\ Spaces/", "external folder display text preserves the real folder path as one escaped reference");
eq(calls.submit.join(","), "inspect @__reasonix_external_folder/mock/Folder-With-Spaces/", "external folder submit text uses the session ref token");
await act(async () => {
@@ -2099,16 +2088,16 @@ console.log("\ncomposer goal toggle");
{
const dom = installDom();
let savedFiles = 0;
const restoreUpload = mockFileUpload(async () => {
savedFiles += 1;
return ".reasonix/attachments/notes.txt";
});
mockApp({
Commands: async () => [{ name: "skill", description: "Manage skills", kind: "builtin" }],
ListDirForTarget: async () => [fileEntry("README.md")],
SearchFileRefsForTarget: async () => [],
ListSessions: async () => [{ path: "/sessions/recent.jsonl", title: "Recent session", current: false }],
SavePastedFileForTarget: async () => {
savedFiles += 1;
return ".reasonix/attachments/notes.txt";
},
});
}, { getPathForFile: () => "" });
const { root, rerender } = await renderComposer();
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
@@ -2175,7 +2164,7 @@ console.log("\ncomposer goal toggle");
await flushTimers();
});
await waitFor("attachment chosen from add-content menu", () => savedFiles === 1);
eq(savedFiles, 1, "attachment action reuses the existing file-save path");
eq(savedFiles, 1, "attachment action streams the chosen File exactly once");
await replaceComposerDraft(rerender, 3001, "@");
await waitFor("workspace menu before plus toggle", () => Boolean(document.querySelector(".slashmenu")));
@@ -2257,6 +2246,7 @@ console.log("\ncomposer goal toggle");
root.unmount();
});
dom.window.close();
restoreUpload();
}
{
@@ -0,0 +1,204 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import { flushAllSessionComposers, resumeSessionComposerEditing } from "../lib/sessionComposerPersistence";
import { makeSessionUIMock } from "../lib/sessionUIMock";
import { installDesktopHostStub } from "./desktopHostStub";
import { createFileUploadMock, dispatchPasteFile } from "./fileUploadStub";
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
}
const tick = () => new Promise<void>(done => setImmediate(done));
const textarea = () => document.querySelector<HTMLTextAreaElement>("textarea")!;
const sendButton = () => document.querySelector<HTMLButtonElement>(".composer__btn--send")!;
async function menu(index: number) {
await act(async () => {
textarea().dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
});
await act(async () => { document.querySelectorAll<HTMLButtonElement>(".context-menu__item")[index].click(); await tick(); });
}
async function fixture(session: string, commands: Record<string, unknown> = {}, formal = true) {
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost/", pretendToBeVisual: true });
Object.assign(globalThis, {
window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
});
for (const name of ["Node", "HTMLElement", "HTMLTextAreaElement", "Event", "CustomEvent", "File", "FileReader", "MutationObserver"]) {
Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(dom.window, name) });
}
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value() {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value() {} });
window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as typeof window.matchMedia;
const backend = makeSessionUIMock(async () => {});
const host = installDesktopHostStub({
Commands: async () => [], Models: async () => [], ModelsForTab: async () => [],
ListDir: async () => [], ListDirForTab: async () => [], SearchFileRefs: async () => [], SearchFileRefsForTab: async () => [],
...backend, ...commands,
}, { clipboardWriteResult: () => false });
const ref = { hostId: "local", sessionId: session };
const sent: string[] = [];
let props: Parameters<typeof Composer>[0] = {
running: false, collaborationMode: "normal", toolApprovalMode: "ask", goal: "", cwd: "/repo",
modelLabel: "DeepSeek-R1", tabId: session, sessionKey: session, ready: true,
formalSessionRef: formal ? ref : undefined,
onSend: async text => { sent.push(text); },
onCancel: async () => ({ discardedItemIds: [] }), onCycleMode() {}, onSetMode() {},
onSetCollaborationMode() {}, onSetToolApprovalMode() {}, onClearGoal() {}, onSwitchModel() {}, onSetEffort() {},
};
const root = createRoot(document.getElementById("root")!);
const paint = async (next: Partial<typeof props> = {}) => {
props = { ...props, ...next };
await act(async () => { root.render(<LocaleProvider><ToastProvider><Composer {...props}/></ToastProvider></LocaleProvider>); await tick(); });
};
await paint();
let insertId = 0;
return { ref, backend, sent, paint,
async insert(text: string) { await paint({ insertRequest: { id: ++insertId, text, mode: "replace" } }); },
async select(from: number, to = from) {
await act(async () => { await new Promise<void>(done => requestAnimationFrame(() => done())); });
textarea().setSelectionRange(from, to);
},
saved: async () => JSON.parse((await backend.GetSessionComposerState(ref)).contentJson),
async dispose() {
await act(async () => { resumeSessionComposerEditing(); root.unmount(); });
host.uninstall(); dom.window.close();
},
};
}
for (const kind of ["file", "image", "clipboard", "native"] as const) {
test(`accepted ${kind} input blocks sending through publication and close`, { timeout: 15000 }, async () => {
const entered = deferred(), release = deferred();
const f = await fixture(kind, {
CaptureAttachmentTarget: async () => { entered.resolve(); await release.promise; return { token: "input-target", capabilities: ["attachments-v2"] }; },
ReleaseAttachmentTarget: async () => {},
StageImageForTarget: async () => ({ draftId: "0123456789abcdef0123456789abcdef", displayName: "accepted.png", mime: "image/png", width: 1, height: 1, bytes: 12 }),
SavePastedImageForComposerTarget: async () => ".reasonix/attachments/accepted.png",
SaveClipboardImageForTarget: async () => ".reasonix/attachments/native.png",
AttachmentDataURLForTarget: async () => "data:image/png;base64,aGVsbG8=",
ReadDraftImageForTarget: async () => "data:image/png;base64,aGVsbG8=",
});
const restoreUpload = createFileUploadMock("input-target", {
ok: (value, message) => assert.ok(value, message), eq: (a, b, message) => assert.equal(a, b, message),
})(async () => ".reasonix/attachments/accepted.txt");
Object.defineProperty(navigator, "clipboard", { configurable: true, value: {
read: async () => kind === "native" ? [{ types: ["image/png"] }] : [],
readText: async () => { entered.resolve(); await release.promise; return " ACCEPTED PASTE"; },
}});
try {
await f.insert("original message");
await f.select(16);
if (kind === "clipboard" || kind === "native") await menu(4);
else await act(async () => { dispatchPasteFile(textarea(), new File(["hello"], kind === "image" ? "accepted.png" : "accepted.txt", { type: kind === "image" ? "image/png" : "text/plain" })); });
await entered.promise;
assert.equal(sendButton().disabled, true, "send is blocked even before target capture/read completes");
await act(async () => {
sendButton().click();
textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await tick();
});
assert.deepEqual(f.sent, []);
let closed = false, closing!: Promise<void>;
await act(async () => { closing = flushAllSessionComposers().then(() => { closed = true; }); await tick(); });
assert.equal(closed, false, "close waits for accepted input");
await act(async () => { release.resolve(); await closing; });
const saved = await f.saved();
if (kind === "clipboard") assert.equal(saved.text, "original message ACCEPTED PASTE");
else { assert.equal(saved.text, "original message"); assert.equal(saved.attachments.length, 1); }
await act(async () => resumeSessionComposerEditing());
assert.equal(sendButton().disabled, false, "completed input releases the send guard");
if (kind === "clipboard") {
await act(async () => { sendButton().click(); await tick(); });
await act(async () => { await flushAllSessionComposers(); });
assert.deepEqual(f.sent, ["original message ACCEPTED PASTE"]);
assert.deepEqual(await f.saved(), {}, "only the complete submitted draft is cleared");
}
} finally { release.resolve(); restoreUpload(); await f.dispose(); }
});
}
for (const kind of ["changed", "unchanged", "background", "local-changed", "clipboard-failed"] as const) {
test(`cut preserves ${kind} content and participates in close draining`, { timeout: 15000 }, async () => {
const entered = deferred(), release = deferred();
const f = await fixture("cut-" + kind, {}, kind !== "local-changed");
let copied = "";
Object.defineProperty(navigator, "clipboard", { configurable: true, value: {
writeText: async (text: string) => {
entered.resolve(); await release.promise;
if (kind === "clipboard-failed") throw Error("clipboard unavailable");
copied = text;
},
}});
try {
await f.insert("abcdef");
await f.select(1, 4);
await menu(2);
await entered.promise;
if (kind === "changed" || kind === "local-changed") await f.insert("NEW CONTENT");
if (kind === "background") await f.paint({
tabId: "other", sessionKey: "other", formalSessionRef: { hostId: "local", sessionId: "other" }, insertRequest: null,
});
let closed = false, closing!: Promise<void>;
if (kind !== "local-changed") {
await act(async () => { closing = flushAllSessionComposers().then(() => { closed = true; }); await tick(); });
assert.equal(closed, false, "close waits for an admitted cut");
}
await act(async () => { release.resolve(); if (closing) await closing; else await tick(); });
assert.equal(copied, kind === "clipboard-failed" ? "" : "bcd");
if (kind === "local-changed") assert.equal(textarea().value, "NEW CONTENT");
else {
const expected = kind === "changed" ? "NEW CONTENT" : kind === "clipboard-failed" ? "abcdef" : "aef";
assert.equal((await f.saved()).text, expected);
await act(async () => resumeSessionComposerEditing());
if (kind === "background") {
assert.equal(textarea().value, "", "background cut never mutates the current composer");
await f.paint({ tabId: f.ref.sessionId, sessionKey: f.ref.sessionId, formalSessionRef: f.ref });
}
assert.equal(textarea().value, expected);
}
} finally { release.resolve(); await f.dispose(); }
});
}
test("native paste fallback owns input before its timer fires", { timeout: 15000 }, async () => {
const entered = deferred(), release = deferred();
const f = await fixture("native-timer", {
CaptureAttachmentTarget: async () => { entered.resolve(); await release.promise; return { token: "timer-target", capabilities: ["attachments-v2"] }; },
ReleaseAttachmentTarget: async () => {},
SaveClipboardImageForTarget: async () => ".reasonix/attachments/native.png",
AttachmentDataURLForTarget: async () => "data:image/png;base64,aGVsbG8=",
});
const originalTimer = window.setTimeout;
let fire!: () => void;
try {
await f.insert("with native image");
window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
if (delay === 160) { fire = handler as () => void; return -1; }
return originalTimer(handler, delay, ...args);
}) as typeof window.setTimeout;
await act(async () => { textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "v", ctrlKey: true, bubbles: true })); });
assert.equal(typeof fire, "function");
assert.equal(sendButton().disabled, true);
let closed = false, closing!: Promise<void>;
await act(async () => { closing = flushAllSessionComposers().then(() => { closed = true; }); await tick(); });
assert.equal(closed, false);
await act(async () => { fire(); await entered.promise; });
assert.equal(closed, false);
await act(async () => { release.resolve(); await closing; });
const saved = await f.saved();
assert.equal(saved.text, "with native image");
assert.equal(saved.attachments[0].path, ".reasonix/attachments/native.png");
} finally { window.setTimeout = originalTimer; release.resolve(); await f.dispose(); }
});
@@ -0,0 +1,346 @@
// Run: tsx src/__tests__/composer-oversize-attachment.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
import { installDesktopHostStub, type DesktopHostStubOptions } from "./desktopHostStub";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
for (let i = 0; i < attempts; i++) {
if (check()) return;
await act(async () => {
await flushTimers();
});
}
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.InputEvent = dom.window.InputEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.ResizeObserver = TestResizeObserver;
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
return dom;
}
function installBridgeApp(methods: Record<string, unknown>, options?: DesktopHostStubOptions) {
const legacySave = methods.SavePastedImageForTarget as ((token: string, dataURL: string) => Promise<string>) | undefined;
const legacyPreview = methods.AttachmentDataURLForTarget as ((token: string, path: string) => Promise<string>) | undefined;
const unscopedPreview = methods.AttachmentDataURL as ((path: string) => Promise<string>) | undefined;
return installDesktopHostStub({
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
CaptureAttachmentTarget: async () => ({ token: "test-attachment-target", capabilities: ["attachments-v2"] }),
ReleaseAttachmentTarget: async () => {},
StageImageForTarget: async (token: string, _operationID: string, displayName: string, mime: string, dataURL: string) => ({
draftId: "",
path: legacySave ? await legacySave(token, dataURL) : ".reasonix/attachments/mock.png",
displayName,
mime,
width: 1,
height: 1,
bytes: dataURL.length,
}),
ReadDraftImageForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
AttachmentDataURLForTarget: legacyPreview ?? (async () => "data:image/png;base64,iVBORw0KGgo="),
AttachmentDataURLForTab: async (_tabID: string, path: string) => unscopedPreview ? unscopedPreview(path) : "data:image/png;base64,iVBORw0KGgo=",
...methods,
}, options);
}
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal",
toolApprovalMode: "ask" as ToolApprovalMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
imageInputEnabled: true,
tabId: "single-surface-tab",
sessionKey: "session:project:/repo:topic-a:session-a",
onSend: () => {},
onCancel: async () => ({ discardedItemIds: [] }),
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: (_mode: CollaborationMode) => {},
onSetToolApprovalMode: () => {},
onClearGoal: () => {},
onSwitchModel: () => {},
onSetEffort: () => {},
ready: true,
...props,
};
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<div className="chat-pane">
<Composer {...currentProps} />
</div>
</ToastProvider>
</LocaleProvider>,
);
await flushTimers();
});
};
await paint();
return { root, rerender: paint };
}
function textarea(): HTMLTextAreaElement {
const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!node) throw new Error("composer textarea did not render");
return node;
}
function sendButton(): HTMLButtonElement {
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!node) throw new Error("send button did not render");
return node;
}
function contextItemCount(): number {
return document.querySelectorAll(".composer-context__item").length;
}
function toastText(): string {
const items = Array.from(document.querySelectorAll(".toast__text"));
return (items.at(-1)?.textContent ?? "").trim();
}
function imagePasteEvent(file: File): Event {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [file],
items: [],
types: [file.type],
getData: () => "",
},
});
return event;
}
function pasteEvent(file: File): Event {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: { files: [file], items: [], types: [file.type], getData: () => "" },
});
return event;
}
function oversized(name: string, type: string, bytes: number): File {
const file = new File(["x"], name, { type, lastModified: 1 });
Object.defineProperty(file, "size", { configurable: true, value: bytes });
return file;
}
console.log("\ncomposer oversize attachment");
for (const [name, type, bytes] of [
["huge.png", "image/png", 64 * 1024 * 1024 + 1],
] as const) {
const dom = installDom();
let reads = 0;
let digests = 0;
let saves = 0;
const origRead = dom.window.FileReader.prototype.readAsDataURL;
dom.window.FileReader.prototype.readAsDataURL = function (this: FileReader, blob: Blob) {
reads += 1;
return origRead.call(this, blob);
};
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { randomUUID: () => "id", subtle: { digest: async () => { digests += 1; return new ArrayBuffer(32); } } },
});
installBridgeApp({
SavePastedFileForTarget: async () => { saves += 1; return ".reasonix/attachments/x.bin"; },
StageImageForTarget: async () => { saves += 1; throw new Error("must not stage"); },
});
const { root } = await renderComposer({ imageInputEnabled: true });
const file = oversized(name, type, bytes);
const blobRead = file.arrayBuffer;
let buffered = 0;
file.arrayBuffer = () => { buffered += 1; return blobRead.call(file); };
await act(async () => {
textarea().dispatchEvent(pasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => toastText() !== "");
eq(reads, 0, `${name}: FileReader never started`);
eq(buffered, 0, `${name}: file bytes never buffered for hashing`);
eq(digests, 0, `${name}: no digest computed`);
eq(saves, 0, `${name}: nothing sent to the kernel`);
eq(contextItemCount(), 0, `${name}: no attachment added`);
ok(/too large/i.test(toastText()), `${name}: toast says the file is too large (got "${toastText()}")`);
await act(async () => root.unmount());
dom.window.close();
}
for (const entry of ["pick", "paste"] as const) {
for (const nativePath of ["", "/repo/releases/large app.apk"]) {
for (const [name, type, bytes] of [
["large app.apk", "application/vnd.android.package-archive", 900 * 1024 * 1024],
["pack.zip", "application/zip", 25 * 1024 * 1024 + 1],
["small.zip", "application/zip", 2],
] as const) {
const dom = installDom();
const label = `${entry} ${nativePath ? "native" : "pathless"} ${name}`;
let reads = 0;
let digests = 0;
let buffered = 0;
let legacySaves = 0;
const nativeCalls: Array<{ token: string; path: string }> = [];
const uploads: Array<{ input: string; init: RequestInit }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init = {}) => {
uploads.push({ input: String(input), init });
return new Response(JSON.stringify({ path: ".reasonix/attachments/uploaded.apk" }), {
status: 201, headers: { "content-type": "application/json" },
});
};
const originalReader = dom.window.FileReader.prototype.readAsDataURL;
dom.window.FileReader.prototype.readAsDataURL = function (this: FileReader, blob: Blob) {
reads++;
return originalReader.call(this, blob);
};
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { randomUUID: () => "id", subtle: { digest: async () => { digests++; return new ArrayBuffer(32); } } },
});
installBridgeApp({
SavePastedFileForTarget: async () => { legacySaves++; return ".reasonix/attachments/legacy.bin"; },
AttachDroppedForTarget: async (token: string, path: string) => {
nativeCalls.push({ token, path });
return { kind: "workspace", path: "releases/large app.apk", isDir: false };
},
}, { getPathForFile: () => nativePath });
const { root } = await renderComposer({ imageInputEnabled: true });
const file = oversized(name, type, bytes);
file.arrayBuffer = async () => { buffered++; return new ArrayBuffer(1); };
await act(async () => {
if (entry === "paste") textarea().dispatchEvent(pasteEvent(file));
else {
const input = document.querySelector<HTMLInputElement>("input[type=file]");
if (!input) throw new Error("file picker did not render");
Object.defineProperty(input, "files", { configurable: true, value: [file] });
input.dispatchEvent(new Event("change", { bubbles: true }));
}
await flushTimers();
await flushTimers();
});
await waitFor(() => contextItemCount() > 0);
eq(contextItemCount(), 1, `${label}: appears in chat context`);
eq(toastText(), "", `${label}: no size or upload error`);
eq(reads, 0, `${label}: FileReader never starts`);
eq(buffered, 0, `${label}: file bytes never buffered`);
eq(digests, 0, `${label}: no full-file digest`);
eq(legacySaves, 0, `${label}: no Base64 RPC`);
eq(nativeCalls.length, nativePath ? 1 : 0, `${label}: native reference used when available`);
eq(uploads.length, nativePath ? 0 : 1, `${label}: HTTP upload only for a pathless file`);
if (nativeCalls[0]) {
eq(nativeCalls[0].token, "test-attachment-target", `${label}: native reference bound to source target`);
eq(nativeCalls[0].path, nativePath, `${label}: original native path preserved`);
ok(document.body.textContent?.includes("large app.apk") === true, `${label}: workspace reference is visible`);
}
if (uploads[0]) {
const uploaded = uploads[0];
const url = new URL(uploaded.input, "http://localhost/");
eq(url.pathname, "/__reasonix_attachment_upload", `${label}: raw upload endpoint`);
eq(url.searchParams.get("target"), "test-attachment-target", `${label}: target token preserved`);
eq(url.searchParams.get("name"), name, `${label}: filename preserved`);
eq(uploaded.init.method, "POST", `${label}: POST upload`);
eq(uploaded.init.body, file, `${label}: original File is the request body`);
eq(new Headers(uploaded.init.headers).get("content-type"), "application/octet-stream", `${label}: raw binary content type`);
}
await act(async () => root.unmount());
dom.window.close();
globalThis.fetch = originalFetch;
}
}
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -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);
@@ -10,6 +10,7 @@ import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcu
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
import { installDesktopHostStub } from "./desktopHostStub";
import { createFileUploadMock, dispatchPasteFile } from "./fileUploadStub";
let passed = 0;
let failed = 0;
@@ -941,11 +942,11 @@ console.log("\ncomposer session draft");
const saveStarted = deferred<void>();
const savePastedFile = deferred<string>();
const sent: string[] = [];
installBridgeApp({
SavePastedFileForTarget: async () => {
saveStarted.resolve();
return savePastedFile.promise;
},
installBridgeApp({});
const restoreUpload = createFileUploadMock("test-attachment-target", { ok, eq })(async (file) => {
eq(file.name, "draft.txt", "pending upload preserves the source file");
saveStarted.resolve();
return savePastedFile.promise;
});
const { root, rerender } = await renderComposer({
onSend: (text) => {
@@ -953,19 +954,9 @@ console.log("\ncomposer session draft");
},
});
const file = new File(["draft attachment"], "draft.txt", { type: "text/plain", lastModified: 1 });
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [file],
items: [],
types: [],
getData: () => "",
},
});
await act(async () => {
textarea().dispatchEvent(event);
dispatchPasteFile(textarea(), file);
await saveStarted.promise;
});
await rerender({ insertRequest: { id: 10, text: "session A waits for its attachment", mode: "replace" } });
@@ -993,6 +984,7 @@ console.log("\ncomposer session draft");
root.unmount();
});
dom.window.close();
restoreUpload();
}
{
@@ -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,12 +16,14 @@ 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"],
["HTTP 401", "error.auth"], ["HTTP 403", "error.forbidden"],
["HTTP 402", "error.quota"], ["HTTP 404", "error.endpoint"],
["request timed out", "error.timeout"], ["empty provider response", "error.empty"],
["no_deliverable_output: model finished without a visible final answer after recovery", "error.noDeliverable"],
["model stream interrupted: unexpected EOF", "error.interrupted"],
["open /tmp/file: permission denied", "error.permission"],
["open /tmp/file: no such file or directory", "error.fileMissing"],
@@ -36,6 +38,26 @@ 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.deepEqual(presentError("saved failure", translate, locale, { kind: "no_deliverable_output" }), {
summary: dict["error.noDeliverable"], detail: "saved failure",
});
assert.deepEqual(presentError("Image processing failed: configured image understanding model does not accept images", translate, locale, { kind: "image_input" }), {
summary: dict["error.imageInput"], detail: "Image processing failed: configured image understanding model does not accept images",
});
for (const [kind, key] of [
["request_send_timeout", "error.requestSendTimeout"],
["transport_eof", "error.transportEOF"],
["transport_reset", "error.transportReset"],
["transport_timeout", "error.timeout"],
["transport_dns", "error.dns"],
] as const) {
assert.equal(presentError("saved provider failure", translate, locale, { kind }).summary, dict[key]);
}
assert.deepEqual(presentError("unknown provider failure", translate, locale, { kind: "future_transport_reason" }), presentError("unknown provider failure", translate, locale));
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 +81,12 @@ 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)>";
const imageError = "Image processing failed: unknown vision model fixture/vision";
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="image-error"><ErrorMessage error={imageError} diagnostic={{ kind: "image_input" }} /></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 +100,17 @@ 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("#image-error .user-error__summary")?.textContent, en["error.imageInput"]);
await act(async () => document.querySelector<HTMLButtonElement>("#image-error .user-error__toggle")!.click());
assert.equal(document.querySelector("#image-error .user-error__detail")?.textContent, imageError);
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"));
@@ -0,0 +1,32 @@
type Checks = {
ok(value: boolean, label: string): void;
eq(actual: unknown, expected: unknown, label: string): void;
};
export function createFileUploadMock(target: string, { ok, eq }: Checks) {
return (upload: (file: File) => Promise<string>) => {
const previous = globalThis.fetch;
globalThis.fetch = async (input, init = {}) => {
const url = new URL(String(input), "http://localhost/");
eq(url.pathname, "/__reasonix_attachment_upload", "ordinary file uses the raw upload endpoint");
eq(url.searchParams.get("target"), target, "upload carries the source attachment target");
eq(init.method, "POST", "ordinary file upload uses POST");
ok(init.body instanceof File, "ordinary file upload sends the File directly");
eq(new Headers(init.headers).get("content-type"), "application/octet-stream", "ordinary file upload uses raw binary content type");
const file = init.body as File;
eq(url.searchParams.get("name"), file.name, "upload preserves the filename");
const path = await upload(file);
return new Response(JSON.stringify({ path }), { status: 201, headers: { "content-type": "application/json" } });
};
return () => { globalThis.fetch = previous; };
};
}
export function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: { files: [file], items: [], types: ["Files"], getData: () => "" },
});
textarea.dispatchEvent(event);
}
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { makeSessionUIMock } from "../lib/sessionUIMock";
import { sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence";
import { beginPersistedComposerSubmission, sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence";
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
@@ -18,11 +18,22 @@ try {
await act(async () => { root.render(<Probe />); });
const target = editor.target!;
await act(async () => { target.onPatch!(target.draftId, target.generation, { text: "你好" }); });
let finishInput!: () => void;
const pending = new Promise<void>(done => { finishInput = done; });
const input = target.trackTask!(target.draftId, target.generation, pending.then(() => {
target.onPatch!(target.draftId, target.generation, { text: "你好 with accepted input" });
}), true);
await act(async () => {
await assert.rejects(beginPersistedComposerSubmission(ref.sessionId, "premature", "{}"), /Input is still being prepared/);
});
assert.equal((await backend.GetSessionComposerState(ref)).submissionId, undefined);
await act(async () => { finishInput(); await input; });
assert.equal(editor.target!.initial.text, "你好 with accepted input");
let sends = 0;
await act(async () => {
// As in Composer.submit, track the submission itself for exit persistence.
await target.trackTask!(target.draftId, target.generation,
sendPersistedComposer(ref.sessionId, "你好", "你好", "formal-diagnostic-send", async () => { sends++; }));
sendPersistedComposer(ref.sessionId, "你好 with accepted input", "你好 with accepted input", "formal-diagnostic-send", async () => { sends++; }));
});
assert.equal(sends, 1);
assert.equal(editor.blocked, false);
@@ -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,127 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import type { ModelSettingsChange, ProviderView } from "../lib/types";
import { selectSettingsValue, settingsOptionValues } from "./settingsSelectTestUtils";
const dom = new JSDOM("<html><body><div id='root'></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true, window: dom.window, document: dom.window.document,
Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, Event: dom.window.Event,
CustomEvent: dom.window.CustomEvent, KeyboardEvent: dom.window.KeyboardEvent, MouseEvent: dom.window.MouseEvent,
localStorage: dom.window.localStorage, sessionStorage: dom.window.sessionStorage,
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
});
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
window.scrollTo = () => {};
window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { configurable: true, value: () => ({x:10,y:10,top:10,left:10,right:310,bottom:54,width:300,height:44,toJSON(){return {};}}) });
const { createRoot } = await import("react-dom/client");
const { LocaleProvider } = await import("../lib/i18n");
const { ModelsSection, ProviderEditor } = await import("../components/SettingsPanel");
const { baseSettings, installCanvasMock } = await import("../test-support/settingsTestFixtures");
const { installDesktopHostStub } = await import("./desktopHostStub");
installCanvasMock(dom.window as unknown as Window);
const changes: Array<{ field?: string; ref?: string }> = [];
installDesktopHostStub({ ApplyModelSettings: async (change: ModelSettingsChange) => {
changes.push(change);
return {requestId:change.requestId, persisted:true, revision:"saved", application:"not_required", targets:[], issues:[], appliedCatalogs:[]};
} });
const provider: ProviderView = {
name: "gateway", kind: "openai", baseUrl: "https://gateway.invalid/v1",
models: ["reasoner", "text-model"], default: "text-model", added: true, builtIn: false,
modelsUrl: "", apiKeyEnv: "", keySet: false, requiresKey: false, configured: true,
visionModels: [], balanceUrl: "", contextWindow: 0, reasoningProtocol: "", thinking: "",
supportedEfforts: [], defaultEffort: "low",
modelOverrides: [{model:"text-model", reasoningProtocol:"none", supportedEfforts:[], defaultEffort:""}],
modelCapabilities: [
{model:"reasoner", inputModalities:[], state:"unknown", source:"unknown",
reasoning:{state:"supported", options:[{id:"low",name:"low"},{id:"high",name:"high"}]}},
{model:"text-model", inputModalities:[], state:"unsupported", source:"override",
reasoning:{state:"unsupported", options:[]}},
],
};
const host = document.getElementById("root")!;
const root = createRoot(host);
const settings = baseSettings();
settings.providers = [provider];
settings.defaultModel = "gateway/reasoner";
settings.plannerModel = settings.defaultModel;
settings.subagentModel = settings.defaultModel;
const render = async () => act(async () => {
root.render(<LocaleProvider><ModelsSection s={{...settings}} busy={false} subtab="usage"
apply={async fn => { await fn(); return true; }} backgroundApply={async () => true} /></LocaleProvider>);
});
const effortControl = () => host.querySelector<HTMLButtonElement>('button[aria-label="Subagent reasoning effort"]')!;
await render();
const planner = host.querySelector<HTMLButtonElement>('button[aria-label="Dedicated planner model"]')!;
assert.ok(planner, "planner control exists");
assert.equal(planner.textContent, "reasoner", "explicit planner remains visible even when equal to default");
await selectSettingsValue(planner, "");
assert.equal(changes.at(-1)?.field, "planner");
assert.equal(changes.at(-1)?.ref, "", "follow clears the explicit planner");
assert.deepEqual(await settingsOptionValues(effortControl()), ["", "low", "high"], "resolved options work without legacy levels");
await selectSettingsValue(effortControl(), "high");
assert.equal(changes.at(-1)?.field, "subagent_effort");
assert.equal(changes.at(-1)?.ref, "high");
settings.subagentModel = "";
await render();
assert.deepEqual(await settingsOptionValues(effortControl()), ["", "low", "high"], "follow-parent selection uses the backend's default-model validation target");
settings.subagentModel = "gateway/text-model";
provider.supportedEfforts = ["low", "high"];
await render();
assert.deepEqual(await settingsOptionValues(effortControl()), [""], "unsupported model does not inherit stale connection levels");
settings.subagentEffort = "high";
await render();
await act(async () => effortControl().click());
assert.equal(document.querySelector('[role="option"][data-value="high"]')?.getAttribute("aria-disabled"), "true", "stale selection is visible but unavailable");
await act(async () => effortControl().click());
await selectSettingsValue(effortControl(), "");
assert.equal(changes.at(-1)?.ref, "", "stale effort can be cleared");
settings.subagentEffort = "";
settings.subagentModel = "gateway/reasoner";
provider.modelCapabilities![0].reasoning = {state:"unknown", options:[]};
await render();
assert.deepEqual(await settingsOptionValues(effortControl()), [""], "unknown resolved capability does not fall back to stale levels");
settings.subagentModel = "gateway/text-model";
provider.modelCapabilities = [];
await render();
assert.deepEqual(await settingsOptionValues(effortControl()), [""], "legacy per-model reasoning=none still disables levels");
settings.subagentModel = "gateway/reasoner";
await render();
assert.deepEqual(await settingsOptionValues(effortControl()), ["", "low", "high"], "older hosts retain declared levels");
async function saveRename(initial: ProviderView, disableReasoning = false): Promise<ProviderView> {
let saved: ProviderView | undefined;
await act(async () => root.render(<LocaleProvider><ProviderEditor key={initial.name} initial={initial} kinds={["openai"]} busy={false}
onCancel={() => {}} onSave={value => { saved = value; }} /></LocaleProvider>));
const name = host.querySelector<HTMLInputElement>(".provider-name-input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(name, "Renamed only");
name.dispatchEvent(new window.Event("input", { bubbles: true }));
});
if (disableReasoning) {
await selectSettingsValue(host.querySelector<HTMLButtonElement>('button[aria-label="Model capability mode"]')!, "none");
}
const save = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find(el => el.textContent?.trim() === "Save changes")!;
assert.ok(save && !save.disabled);
await act(async () => save.click());
assert.ok(saved);
return saved;
}
const saved = await saveRename({...provider, name:"preserve", supportedEfforts:[]});
assert.equal(saved.default, "text-model", "rename preserves non-first default model");
assert.equal(saved.defaultEffort, "low", "rename preserves default with inferred levels");
assert.deepEqual(saved.supportedEfforts, [], "automatic options are not frozen into config");
const removed = await saveRename({...provider, name:"removed", models:["reasoner"]});
assert.equal(removed.default, "reasoner", "removed default falls back to an enabled model");
const invalid = await saveRename({...provider, name:"diagnostic", defaultEffort:"future-level"});
assert.equal(invalid.defaultEffort, "future-level", "unrelated edit preserves an invalid declaration for backend diagnostics");
const disabled = await saveRename({...provider, name:"explicit-off"}, true);
assert.equal(disabled.defaultEffort, "", "explicitly disabling reasoning clears its default");
assert.deepEqual(disabled.supportedEfforts, []);
await act(async () => root.unmount());
dom.window.close();
console.log("model preferences capability and preservation regressions passed");
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { projectSessionRowKey } from "../lib/projectSessionIdentity";
import type { ProjectNode } from "../lib/types";
const dom = new JSDOM("<div id='root'></div>");
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
const source: ProjectNode = { key: "source", kind: "global_topic", label: "History",
source: { hostId: "local", sourceKey: "row-identity-source", path: "/history.jsonl" } };
const sourceKey = projectSessionRowKey(source);
const original: ProjectNode = { key: "original", kind: "global_topic", label: "History",
session: { hostId: "local", sessionId: "row-original" }, identityAliases: [sourceKey] };
const reviewed: ProjectNode = { ...original, key: "reviewed",
session: { hostId: "local", sessionId: "row-reviewed" } };
assert.equal(projectSessionRowKey(original), sourceKey, "adoption preserves the mounted source row");
assert.notEqual(projectSessionRowKey({ ...source,
source: { ...source.source!, sourceKey: "unowned-source" }, identityAliases: [sourceKey] }), sourceKey,
"an unowned source cannot borrow an already canonical key through a secondary alias");
const root = createRoot(document.getElementById("root")!);
const warnings: unknown[][] = [];
const oldError = console.error;
console.error = (...args) => { warnings.push(args); };
try {
// Old peers/previously loaded pages may still publish a conflicting alias.
// Reorder siblings and insert/remove a third row as catalog pushes do.
const other: ProjectNode = { key: "other", kind: "global_topic", label: "Other",
session: { hostId: "local", sessionId: "row-other" } };
for (let cycle = 0; cycle < 20; cycle++) {
const rows = cycle % 2 ? [source, other, reviewed, original] : [original, reviewed];
await act(async () => root.render(<ul>{rows.map(row =>
<li key={projectSessionRowKey(row)} data-session={row.session?.sessionId ?? "source"}>{row.label}</li>)}</ul>));
assert.deepEqual(Array.from(document.querySelectorAll("li"), row => row.getAttribute("data-session")),
rows.map(row => row.session?.sessionId ?? "source"), "catalog refresh must not multiply mounted rows");
}
assert.notEqual(projectSessionRowKey(original), projectSessionRowKey(reviewed), "canonical sessions never share a React key");
assert.equal(warnings.length, 0, "no duplicate-key or reconciliation warnings");
} finally {
console.error = oldError;
await act(async () => root.unmount());
dom.window.close();
}
console.log("PASS source adoption preserves identity without merging independent canonical rows");
@@ -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);
Loaded 100 of 1176 files, more files were not shown because too many files have changed in this diff. Show more