Compare commits

...
80 Commits
Author SHA1 Message Date
SivanCola 1986da2661 Merge pull request #7039 from SivanCola/test/windows-acp-prompt-wait
Bound ACP prompt waits on Windows / 限定 Windows ACP 提示等待窗口
2026-07-30 00:56:23 +08:00
SivanCola 690cfe2128 test: bound ACP prompt waits for loaded Windows runners
Problem: the full Windows release sweep timed out two ACP end-to-end prompt scenarios after two seconds, leaving their session lease files locked during cleanup.

Root cause: drainPrompt used a two-second idle limit while exercising the complete ACP server, controller, agent, tool-loop, and transcript-persistence pipeline. Loaded Windows runners can exceed that scheduling window without violating the protocol contract.

Fix: keep the end-to-end helper bounded while extending its idle responsiveness window to five seconds and documenting the covered pipeline.

Verification: go test ./internal/acp -run '^TestE2E(ToolTurnAndPersistence|SessionLoad)$' -count=20; go test -race ./internal/acp -run '^TestE2E(ToolTurnAndPersistence|SessionLoad)$' -count=5; go test ./internal/acp
2026-07-30 00:39:40 +08:00
SivanCola 26d1c423fd Merge pull request #7038 from SivanCola/test/windows-controller-shutdown-wait
Stabilize Windows controller completion waits / 稳定 Windows 控制器完成等待
2026-07-30 00:29:32 +08:00
SivanCola f14b6521e8 test: stabilize controller completion waits on Windows
Problem: Windows release runners can take more than one second to schedule asynchronous controller completion during the full desktop suite, causing Fork or Rewind tests to fail before their semantic assertions and leave lease files locked during cleanup.

Root cause: the shared waitNotRunning test helper used a one-second outer bound that was tighter than loaded Windows runner scheduling latency.

Fix: retain the polling invariant while extending the bounded responsiveness window to five seconds and document why the platform-neutral test needs that allowance.

Verification: go test . -run '^Test(Fork|Rewind)KeepsProjectWorkspacePrompt$' -count=20; go test -race . -run '^Test(Fork|Rewind)KeepsProjectWorkspacePrompt$' -count=5; go test ./...
2026-07-30 00:14:30 +08:00
SivanCola 58c8ff60e8 docs(release): finalize v1.18.0 notes (#7037)
Problem: The existing v1.18.0 catalog covered only the earliest merged work and the automated bilingual generator returned truncated JSON twice.

Fix: Replace the draft with a source-grounded bilingual summary of the full v1.17.21..main-v2 release range, including the Guard automation upgrade note and all major product, reliability, security, and release-channel changes.

Verification: release-notes validate, unit tests, zh/en render, grounded PR reference audit, privacy scan, and git diff --check.
2026-07-29 23:20:12 +08:00
SivanCola ba2c4d31b7 test(config): skip Unix mode assertion on Windows (#7036)
Problem: Windows CI failed because the config-lock registry test required Unix mode 0700 from FileMode.Perm().

Root cause: Windows protects the per-user registry through inherited ACLs and does not represent that ACL through Unix permission bits.

Fix: Skip the Unix-only permission-bit assertion on Windows while retaining the cross-process lock and user-profile registry coverage.

Verification: go test ./internal/config -run 'TestAcquireConfigEditLock|TestLockUserConfigEdits' -count=1; go test ./internal/config; go vet ./internal/config; GOOS=windows GOARCH=amd64 go test -c ./internal/config
2026-07-29 23:12:04 +08:00
SivanCola 44d0b7d82d fix(desktop): prevent stale transcript pixels on Windows / 修复 Windows 会话残影 (#7019)
* fix(desktop): prevent stale transcript pixels on Windows / 修复 Windows 会话残影

* Harden Windows transcript compositor backing

Problem:
- Windows WebView2 can retain stale TurnActions pixels after React removes old chat nodes in Classic and Workbench layouts.
- A broad transcript rule would also alter History and Creation surfaces, while background packs must keep their existing viewport geometry.

Root cause:
- Theme-pack CSS leaves the main scrolling transcript transparent, so WebView2 has no opaque repaint surface when child layers disappear.
- The earlier #6365 repair covered Creation only.

Fix:
- Scope an opaque Canvas-backed compositor surface to the primary Classic and Workbench transcript.
- Recreate background-pack image, pane opacity, task opacity, and safe-area washes on clipped viewport-fixed pseudo-layers without touching History or Creation.
- Expand the source contract to cover selector scope, stacking, viewport geometry, and all theme scene variants.

Verification:
- pnpm exec tsx src/__tests__/turn-actions-rendering.test.ts (9 passed)
- pnpm check:css
- pnpm typecheck
- git diff --check
- Chromium computed-style, scroll-stability, and Windows/non-Windows visual parity checks

* fix(desktop): reveal Windows theme backgrounds

Problem:
Windows theme packs loaded correctly, but their scene images stayed hidden after making Classic and Workbench transcripts opaque for WebView2 repaint correctness.

Root cause:
The image and overlay pseudo-elements retained negative z-index values, so they painted behind the transcript opaque compositor backing.

Fix:
Use nonnegative image and overlay layers, raise only direct transcript children above both layers, and enforce the ordering in the source-contract regression.

Verification:
Focused rendering regression 10/10; CSS syntax and z-index checks; TypeScript; full frontend test and production build; native Win11 Wails build and WebView2 task scroll plus home new-session validation.
2026-07-29 22:35:37 +08:00
b8c82972f2 Harden Guard repair preview confirmation / 加固 Guard 修复预览确认绑定 (#6973)
* fix(guard): bind repair apply to confirmed preview

* Harden Guard preview confirmation binding

Problem: non-interactive apply-plan could accept a bare --yes, while preview IDs omitted derived-state contents and full pending-update identity.

Root cause: confirmation hashes covered only display previews and were checked once before the plan loop, so state-dependent actions could drift after confirmation.

Fix: require a prior preview ID for non-interactive apply-plan, bind opaque file and transaction state, re-check each action immediately before execution, and preserve unrelated multi-action behavior. Update English and Chinese recovery docs.

Verification: go test ./internal/repair ./cmd/reasonix-guard; go test -race ./internal/repair; go vet ./...; go test ./...; git diff --check.

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

* Serialize confirmed Guard repair mutations

Problem: preview IDs were rechecked before mutation, but another process could still replace a target during the final rename window or race on last-repair.json, losing or mixing undo records.

Root cause: repair actions lacked a shared transaction lock and per-target cross-process locks, while confirmed snapshot/config bytes were read again after approval and replacement writes could overwrite a concurrent recreation.

Fix: serialize repair bookkeeping before canonical target locks, revalidate confirmed state inside the lock and around renames, publish restored content with create-only atomic writes, bind complete pending-update identity, preserve concurrent writers, and keep undo under the same lock order. Add focused coverage for wait/recheck, rename windows, snapshot bytes, unreadable LKG nodes, transaction serialization, and rollback identity.

Verification: go test ./internal/fileutil ./internal/config ./internal/repair ./cmd/reasonix-guard; go test -race ./internal/fileutil ./internal/repair; go vet ./...; env -u DEEPSEEK_API_KEY go test ./...; Windows test-binary cross-compilation for internal/repair and cmd/reasonix-guard; git diff --check.

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

* Normalize Windows repair lock test paths

Problem: the Windows CI test waited for a raw backslash path while the lock hook receives the canonical lower-case slash form, so the test timed out before exercising the recheck.

Root cause: the regression test compared platform-specific input text instead of the production lock key representation.

Fix: normalize test hook paths with the same Windows case and separator rules used by repair mutation locks.

Verification: focused repair lock tests pass on macOS; Windows amd64 test-binary compilation passes.

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

* test(jobs): remove StartForSession race

Keep the worker blocked while assertions inspect its state and protect job fields with the existing mutex. This prevents the race job from observing concurrent completion writes when validating accepted input.

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

* fix(repair): bind target identity into Guard preview confirmation

Problem:
- previewId hashed only exported preview fields and content-based state IDs,
  so identical reasonix.toml content in two project roots produced the same
  confirmation and apply-plan --yes could mutate the unconfirmed project.
- Mutation locks used Abs/Clean only, so symlink path aliases (and macOS
  case variants) took different locks for the same real node.
- rollback_update bound only the pending transaction JSON and skipped target
  locks, so a stable pending update could still roll back an unconfirmed
  live release unit.
- When a target was recreated after quarantine rename, the move was not
  recorded in last-repair, so UndoLastRepair could restore an unrelated prior
  repair.

Root cause:
- Confirmation identity omitted canonical target identity; lock keys and
  rollback binding did not converge on real paths or live release-unit state;
  post-rename failure paths returned before persisting the already-moved node.

Fix:
- Embed an irreversible canonical target identity in per-file state IDs used
  by previewId, while verifying quarantined nodes against the original path
  identity.
- Resolve existing symlink parents and case-fold on Windows/macOS when
  building mutation-lock keys and target identities.
- Bind current release-unit file states into rollback preview state, take
  shared target locks during prepare/rollback, and re-check under those locks.
- Persist the quarantine/move transaction when a concurrent recreate is
  detected so undo restores the confirmed node and retains the rewrite as redo.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- go vet ./internal/repair/

* fix(repair): close symlink, app-bundle, and macOS handoff confirmation gaps

Problem:
- canonicalRepairPath EvalSymlinks'd the full path, so two leaf symlinks
  sharing a referent produced the same previewId, and renaming a confirmed
  symlink leaf changed its identity and made apply fail.
- App-bundle rollback bound only directory kind/mode for the live .app and
  omitted the backup tree, so interior executable drift still accepted an
  old previewId and rolled back.
- macOS self-update held mutation locks only while writing the pending
  transaction; the actual mv/ditto ran in a shell helper that ignored the
  shared flock, racing Guard rollback into mixed bundles.

Root cause:
- Leaf nodes are what Lstat/Rename mutate, but identity resolved through them;
  directory release units need tree digests; handoff and repair used different
  processes without a shared lock holder over the swap window.

Fix:
- Resolve only parent-directory symlinks and reattach the original leaf name
  for mutation locks and preview target identity.
- Bind full tree digests for directory release-unit nodes and include backup
  paths in pending-update preview state.
- Replace the macOS shell handoff with a detached self-subprocess that waits
  for the old PID, then holds LockRepairMutations around the directory swap.
- Export LockRepairMutations/Timeout for desktop handoff and add regressions.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- cd desktop && go test -count=1 -run TestMacUpdate .
- go vet ./internal/repair/ and desktop

* fix(repair): secure updater claims and path case locks

Problem:
The detached macOS updater trusted filesystem paths from command-line arguments, and repair identities folded path case by operating system rather than by the target filesystem.

Root cause:
The handoff only checked that a pending-update file existed before swapping bundles. Path canonicalization assumed every macOS and Windows filesystem was case-insensitive.

Fix:
Persist optional handoff paths and owner identity in the pending transaction, claim them under strict pending and target locks, revalidate the staged bundle before swapping, and remove filesystem paths from child arguments. Detect case sensitivity from the actual macOS directory or Windows per-directory flag before folding lock and preview identities.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- cd desktop && go test -count=1 -run TestMacUpdate .
- cd desktop && go test -race -count=1 -run TestMacUpdate .
- go vet ./internal/repair/
- cd desktop && go vet .
- GOOS=windows GOARCH=amd64 go test -c ./internal/repair

* fix(repair): scope case probe helper by platform

Problem:
The Linux golangci-lint job reported existingRepairPathParent as unused.

Root cause:
The helper lived in a common file while its only callers were excluded from Linux builds by Darwin and Windows build tags.

Fix:
Move the helper into a Darwin-or-Windows platform file so it is compiled exactly where the filesystem case probes use it.

Verification:
- golangci-lint run --timeout=5m
- go test ./internal/repair/ -count=1
- cd desktop && go test -count=1 -run TestMacUpdate .
- GOOS=windows GOARCH=amd64 go test -c ./internal/repair

* fix(repair): preserve verified rollback recovery

Problem:
- File rollback could install backup bytes that changed after preflight validation.
- A failed macOS backup restore still cleared pending recovery state and cleanup material.
- NFC and NFD aliases on APFS produced different repair lock and preview identities.

Root cause:
The staged-copy hash was ignored, macOS rollback cleanup was unconditional, and canonical repair paths handled case aliases without filesystem-aware Unicode normalization.

Fix:
Verify the staged rollback bytes against the transaction hash before mutating live targets. Make macOS rollback compensating and retain pending, backup, and staging state when restoration fails. Normalize repair identities to NFD only on APFS and HFS while preserving distinct names on other filesystems.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race -count=1 -run TestMacUpdate .
- go vet ./internal/repair/
- cd desktop && go vet .
- golangci-lint run --timeout=5m
- GOOS=windows GOARCH=amd64 go test -c ./internal/repair

* Harden transactional updater handoffs

Problem:
Repair and self-update paths could continue without a pending-state lock, apply a prepared update after its transaction or release unit changed, or restore an unverified macOS bundle tree. Linux also persisted a CLI sibling that transaction validation rejected.

Root cause:
Pending transitions used a best-effort unbounded lock, file updaters locked paths without claiming the exact prepared transaction, and app-bundle handoff/rollback validation did not bind both the staged tree and original backup tree across the rename window.

Fix:
Use bounded fail-closed pending locks and a consistent pending-to-target lock order. Claim Linux and Windows replacements by version, creation identity, platform, and canonical release-unit paths. Bind macOS staging, original, installed, and backup trees; recover rejected handoffs only when the original bundle is still verified; protect backup paths and reject symlink escapes. Add focused concurrency, drift, rollback, compatibility, and cross-platform tests.

Verification:
- go test ./internal/repair/ ./internal/fileutil/ ./cmd/reasonix-guard/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ ./cmd/reasonix-guard/ -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./... -count=1
- go vet ./... and cd desktop && go vet ./...
- golangci-lint run --timeout=5m ./...
- Windows amd64/arm64 test-binary compilation for repair, desktop, and update-helper

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

* Complete fail-closed repair and updater transactions

Problem:
Repair, undo, and self-update entry points could act on state that changed while waiting for locks. File-update completion also needed a crash-safe installed-unit binding, and platform handoffs could execute mutable or incompletely claimed payloads.

Root cause:
Invocation identity was sampled too late or incompletely, mutable pending state doubled as installed state, cleanup paths did not consistently compare ownership, and Windows/macOS handoffs did not bind every source, backup, staging, and release-unit transition.

Fix:
Bind every mutating invocation before lock waits and recheck it under a pending-to-target lock order. Persist create-only installed-state sidecars, preserve recovery material on ambiguity, use atomic no-replace publication, validate complete app and file trees, isolate and hold the verified Windows installer through execution, and make rollback/undo crash-retryable.

Verification:
go test ./internal/repair ./internal/fileutil ./cmd/reasonix-guard -count=1
cd desktop && go test ./... -count=1
Windows amd64 and arm64 update-helper test-binary compilation
gofmt -l internal/repair desktop
git diff --check

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

* Document fail-closed legacy update recovery

Problem:
Recovery documentation described additive update state as generally backward compatible and a Linux comment still described the obsolete mutable-pending design.

Root cause:
The text was not updated when installed release-unit state moved to a transaction-unique sidecar and legacy destructive actions began requiring explicit ownership proof.

Fix:
Document immutable pending state, crash-safe sidecar recovery, and the distinction between readable legacy transactions and authorization for handoff, healthy commit, or app-bundle rollback.

Verification:
git diff --cached --check
Focused repair and updater tests, full race suites, vet, lint, and cross-platform compilation passed against the merged main-v2 baseline.

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

* Close updater execution and compensation races

Problem:
- Generic failure-marker writes could let a stale invocation overwrite a newer transaction marker.
- Legacy app rollback could move a symlink backup before rejecting it, and file publish could leave a tampered staged node live.
- Windows self-update could execute a helper that changed after transaction prepare, overwrite a concurrently allocated cache path, or add a portable alias that was absent before.
- macOS updater commands were resolved through PATH.

Root cause:
Mutation ownership was not rebound under the pending lock at every public entry point. Helper validation proved only PE architecture, staged-file verification followed symlinks, and helper cache publication used replace semantics.

Fix:
Rebind generic failure markers after lock acquisition, reject non-directory app backups before mutation, require regular-file staged payloads, compensate failed publication by retaining the rejected node and restoring the verified prior file, and preserve pending recovery state. Bind the Windows helper to the exact prepared release-unit SHA-256, create a unique cache node without replacement, freeze it through process creation, preserve the original portable-alias shape, and use absolute macOS system command paths.

Verification:
- go test ./... -count=1
- go test -race ./internal/repair ./internal/fileutil ./cmd/reasonix-guard -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./... -count=1
- go vet ./... and cd desktop && go vet ./...
- golangci-lint 2.12.2 for root and desktop
- cache guard: 8/8 pass
- Windows amd64/arm64 repair, desktop, and update-helper test-binary compilation
- Linux amd64 repair test-binary compilation

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

* Tidy desktop updater dependencies

Problem:
The desktop CI go-mod-tidy gate failed because the updater no longer imports github.com/minio/selfupdate, but the module files still retained that dependency and its stale transitive checksums.

Root cause:
The transactional updater implementation replaced the selfupdate package without running the desktop module tidy gate afterward.

Fix:
Remove the unused selfupdate requirement and the transitive checksum entries that are no longer reachable from the desktop module graph.

Verification:
- cd desktop && go mod tidy (idempotent)
- cd desktop && go vet ./...
- focused workspace-rebuild timing tests: 10/10 pass
- git diff --check

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

* Harden prepared repair journals and Windows release-unit signing

Problem:
- Repair mutations could leave ambiguous undo state after a crash between
  rename and last-repair.json publication.
- Concurrent writers could replace a pending journal or cleanup node after
  ownership checks, turning cleanup into deletion of unowned material.
- Windows portable/installer updates did not bind signed payload identity
  through packaging, and Linux release-unit publication did not return
  exact installed-state receipts.

Root cause:
Undo bookkeeping treated last-repair.json as the only durable intent, cleanup
used path-based remove after verification, and Windows packaging trusted
unsigned payload layout without a create-only signed manifest contract.

Fix:
- Introduce create-only pending-repair.json write-ahead journals, promote or
  discard them under the repair lock, and fail closed on ambiguous recovery.
- Displace cleanup targets to unique siblings, re-verify identity, and never
  unlink a pending journal that changed before cleanup.
- Bind Windows signed payload manifests through packaging/signing and require
  exact installed-state receipts for Linux release-unit publication.

Verification:
- go test ./internal/repair -count=1 (pass)
- focused security coverage for prepared intent, pending cleanup ownership,
  legacy healthy/rollback fail-closed, and macOS staging cleanup

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

* fix(ci): use resolved version for payload manifest

Problem: The Windows payload manifest step referenced a nonexistent ver step output, so actionlint rejected the release workflow.
Root cause: The build job receives the release version from needs.resolve.outputs.version, but the step used steps.ver.outputs.version.
Fix: Bind the signed payload manifest to the resolved release version output.
Verification: actionlint v1.7.7 and scripts/release-workflows.test.sh pass.

* fix(repair): bind prepared create ownership to published Windows modes

Problem:
- Windows CI rejected undo after a crash following snapshot create: the
  restored config file remained because CreatedStateID ownership failed.
- Desktop packaging contract tests still expected steps.ver.outputs.version
  after the release workflow switched to needs.resolve.outputs.version.

Root cause:
- Prepared create intents predicted FileMode 0o600, but Windows Lstat reports
  regular files as 0444/0666. Undo then treated the published file as an
  unowned concurrent write and left it in place.
- The packaging test was not updated with the resolved-version workflow fix.

Fix:
- Predict create-state IDs with platform-published file modes and rebind the
  pending journal to the exact on-disk node after AtomicCreateFile.
- Align the Windows signing packaging contract test with the resolve job
  version output.

Verification:
- go test ./internal/repair -count=1
- go test ./internal/repair -run TestPreparedCreateStateIDMatchesAtomicCreate,TestApplyRepairPlanPersistsMissingSnapshotTargetBeforeCreate
- cd desktop && go test -run TestWindowsReleaseSignsPayloadBeforeRepackaging .

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

* fix(repair): reject prepared create ownership drift

Problem: A file replaced between AtomicCreateFile and ownership rebinding could be claimed by the repair transaction and removed by undo.
Root cause: The pending journal was rewritten to bind whatever state happened to be present after publication instead of proving it matched the precomputed owned state.
Fix: Keep the write-ahead ownership binding immutable and fail closed when the published path differs; add a regression test that preserves a concurrent replacement through undo.
Verification: focused crash/create tests, go test ./internal/repair, repair/fileutil race tests, Guard tests, go vet, and Windows repair test cross-compile pass.

---------

Co-authored-by: SivanCola <[email protected]>
Co-authored-by: PaulDing98 <[email protected]>
2026-07-29 16:52:28 +08:00
SivanColaandffff2004 ac814a5040 fix(release): complete and harden Stable/Preview channels / 补全并加固正式版与预览版渠道 (#6997)
* complete stable and preview channel loop

* fix(site): harden release-channel fallbacks and version scopes

Problem:
Website CLI Stable fallbacks pointed at GitHub releases/latest asset
URLs, incomplete GitHub releases were turned into 404 download links,
and Desktop/CLI Stable races overwrote every .rxv version chip.

Root cause:
CLI releases set make_latest:false so Latest belongs to Desktop Stable;
releaseAssetMap synthesized missing CLI assets; updateStableVersion was
shared across both product surfaces.

Fix:
- CLI fallback always uses the releases page, never /releases/latest.
- Require all 7 CLI assets before accepting a release (match Worker).
- Scope package-manager .rxv updates to CLI Stable only; desktop
  version chips use data-release-version="desktop".

Verification:
node --test site/src/scripts/release-channels.test.mjs
site/src/scripts/download-link.test.mjs

* fix(desktop): unify update channel controls

Problem: The desktop settings page separated channel selection, update status, and install actions across detached rows.

Root cause: The channel picker and updater state machine were rendered as independent controls, so switching channels did not read as one actionable workflow.

Fix: Merge current version/status, Stable and Preview selection, manual refresh, auto-check guidance, and state-specific download/install actions into one compact settings flow. Add the localized channel auto-check hint for English, Simplified Chinese, and Traditional Chinese.

Verification: pnpm --dir desktop/frontend run typecheck; pnpm --dir desktop/frontend run check:css; pnpm --dir desktop/frontend run test:updater (18 passed); pnpm --dir desktop/frontend run build; git diff --cached --check.

* feat(cli): persist release channel selection

Problem:
CLI users had to repeat the verbose --channel flag, and a plain upgrade always returned to Stable instead of following the user's selected public channel.

Root cause:
The native updater had no user-global channel preference, while the website and docs exposed only one-off flag syntax.

Fix:
Add a user-global [cli].update_channel setting, support upgrade stable|preview as persistent switches, retain --channel as a one-off compatibility override, reject conflicting arguments, and synchronize English/Chinese docs plus website commands.

Verification:
- env -u DEEPSEEK_API_KEY GOCACHE=/private/tmp/reasonix-go-cache go test ./...
- env GOCACHE=/private/tmp/reasonix-go-cache go vet ./internal/cli ./internal/config
- npm test (site)
- npm run build (site)
- git diff --check

* fix(release): keep CLI channel pointers monotonic

Problem: Older Stable recovery runs and out-of-order Preview publishers could overwrite a newer public CLI channel pointer.

Root cause: The release workflow wrote cli/<channel>/latest.json unconditionally and had no channel-scoped serialization or strict version comparison.

Fix: Serialize CLI releases by channel, publish immutable per-tag metadata first, validate the current pointer, and move it only when a strict Stable or Preview version tuple is newer.

Verification: bash scripts/release-workflows.test.sh; bash -n scripts/compare-cli-release-tags.sh scripts/release-workflows.test.sh; git diff --check.

* fix(config): preserve symlinked config files

Problem: Persisting the selected CLI update channel could replace a symlinked user config with a regular file.

Root cause: AtomicWriteFile renamed a temporary file over the symlink path instead of the symlink's final target.

Fix: Resolve complete config symlink chains before atomic writes and route both incremental saves and Config.WriteFile through the shared helper.

Verification: go test ./internal/config; focused writable-target and multi-level symlink regression tests; go test ./...; go vet ./internal/cli ./internal/config.

* fix(release): harden stable and preview end-to-end

Problem:
The Stable/Preview loop still allowed stale or cross-channel release pointers, unsafe Desktop release origins and retries, updater events detached from their checked target, and concurrent config writers that could lose user changes.

Root cause:
Release publication lacked one immutable candidate and strict manifest/recovery contracts; Desktop operations were not bound to request, channel, and version; config locks followed logical paths and several writers loaded stale or malformed snapshots.

Fix:
- authorize Desktop candidates from protected main-v2, separate the current control plane from recovered source commits, validate immutable assets, and make GitHub/R2 publication monotonic and resumable
- bind Desktop checks, downloads, verification, and installs to one request/channel/version state machine with strict size and manifest validation
- validate CLI, Desktop, website, and Worker release data consistently across Stable and Preview
- canonicalize and pin config targets, serialize all affected writers, preserve safe symlinks, and fail closed on malformed configuration

Verification:
- go test -race ./...; go vet ./...; go build ./...
- cd desktop && go test -race ./... && go vet ./... && go build ./...
- frontend typecheck, updater tests (31), CSS checks, and production build
- site tests (44) and production build
- Worker typecheck and tests (74)
- release workflow contracts, actionlint, cache guard, shell syntax, diff check, and Linux/Windows cross-builds

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

* fix(config): remove obsolete edit wrappers

Problem:
The release-channel hardening left two private config edit wrappers unreachable, causing the pinned golangci-lint unused check to fail.

Root cause:
Callers now resolve and pin final config targets through the transactional edit path, but the superseded convenience wrappers remained in place.

Fix:
Remove only the two unreferenced wrappers while preserving the resolved-target and timeout helpers used by production code and concurrency tests.

Verification:
- go test ./internal/config
- go vet ./...
- golangci-lint v2.12.2 run --timeout=5m
- git diff --check

* fix(release): satisfy workflow shell lint

Problem:
The final CI lint job passed golangci-lint but failed actionlint because the CLI metadata publisher assigned expected_prerelease without reading it.

Root cause:
Prerelease validation moved into validate-cli-release-manifest.sh, leaving duplicate channel-specific shell assignments in the workflow.

Fix:
Remove the unused assignments and keep prerelease validation centralized in the manifest validator.

Verification:
- actionlint v1.7.7 on all release workflows
- bash scripts/release-workflows.test.sh
- bash -n on release helper scripts
- git diff --check

* fix(release): close desktop channel blockers

Problem:
Desktop Preview-to-Stable switches failed for older Debian Stable builds, updater Wails methods broke older one-argument callers, and config edit locks could split across TMPDIR or macOS case aliases.

Root cause:
The privileged helper rejected every downgrade, strict request binding reused legacy exported method names, and lock identity depended on process-local temp directories without Darwin path folding.

Fix:
Allow only signed public Preview-to-Stable Debian downgrades, preserve legacy updater methods beside strict request-bound APIs, and use a stable hardened per-user lock registry with Darwin alias normalization.

Verification:
Root and Desktop tests, Desktop race, Go vet/build, updater 31/31, site 44/44, Worker 74/74, release workflow contracts, cache guard, actionlint, golangci-lint, frontend typecheck/CSS/build, and focused cross-process/security regressions.

---------

Co-authored-by: ffff2004 <[email protected]>
2026-07-29 15:09:26 +08:00
SivanCola 6c58ef5ef9 Raise test real-spawn timeout to deflake Windows CI (#7026)
Problem: TestLoadNormalizesQuotedNodeEvalHooksPerProject flaked in CI
(test (windows-latest)) — the node -e hook child was killed at the
15.03s timeout on a loaded Windows runner, failing the job.

Root cause: hook_test.go's realSpawnTimeout (15s) feeds the per-hook
Timeout for tests that spawn real child processes. The constant's own
comment says these tests assert behavior, not latency, and node cold
starts can stall for seconds on a loaded machine — 15s just was not
generous enough for a saturated Windows GitHub runner.

Fix: raise realSpawnTimeout to 60s. The ceiling only fires on a genuine
hang, so a larger budget costs nothing when children exit normally.
Tests asserting the timeout path keep their own tight budgets (50-100ms)
and are untouched, so the suite stays fast. All real-spawn tests share
this constant (execution_contract_test.go, windows_batch_test.go,
hook_test.go), so one bump deflakes every call site.

Verification:
- go test ./internal/hook -count=1 passes locally (1.5s, node v22).
- CI on this PR exercises the windows smoke suite, which includes
  internal/hook.
2026-07-29 15:04:39 +08:00
SivanCola 9eb301102e Keep required per-OS checks alive when the path gate skips the test matrix (#7025)
Problem: gating the test matrix at job level means a docs-only pull
request produces no test (ubuntu/macos/windows-latest) check runs at all
— a skipped matrix job reports only the parent job check, never the
per-leg checks the Protect main-v2 ruleset requires. Docs-only PRs then
sit at mergeStateStatus=BLOCKED with the required legs stuck in
"Expected". Confirmed live on a docs-only verification PR.

Fix: the test job now always runs (if: always()), and every step carries
the gate via a job-level RUN_STEPS env. When the changes detector
reports an unrelated diff, all steps skip and each leg reports success
in seconds, so the required per-OS checks exist and pass. When the
changes job itself fails, the legs still start and the empty output
fails open (!= 'false') into the full suite. Required check names are
unchanged; non-required jobs keep job-level skipping.

Verification:
- actionlint v1.7.7 passes; YAML parses cleanly.
- On this PR the full matrix runs (workflow files are not in any skip
  list). The docs-only verification PR will be re-run after merge to
  confirm legs report success and the PR turns mergeable.
2026-07-29 15:04:21 +08:00
SivanCola 770d414e6a Speed up pull request CI: focused race, Windows smoke, push-only installer / 加速 PR CI:聚焦 race、Windows 冒烟、installer 仅主干构建 (#7023)
* Gate CI jobs on changed paths to cut Actions waste

Problem: every pull request runs the full ci.yml matrix (11 jobs across
3 OSes) and the cache-impact guard fires on every PR event, even for
docs-only or otherwise unrelated changes. That is roughly 33k wall-minutes
per month on GitHub-hosted runners — free while the repo is public, but
pure queue time now and a real bill if the repo ever goes private.

Change:
- ci.yml: add a cheap `changes` detection job and gate the heavy jobs on
  it. PRs confined to docs/, site/, release-notes/, workers/ or top-level
  Markdown skip the Go test/race/lint/govulncheck/coverage jobs; PRs that
  cannot affect the desktop module (which imports the root kernel via
  `replace reasonix => ../`) skip the desktop/desktop-macos/desktop-windows
  jobs; the site job only runs for site/ changes. Job-level `if` reports
  skipped, which still satisfies the required status checks (lint, race,
  test) — a workflow-level paths-ignore would leave them pending and block
  merges. Detection fails open, and pushes to main-v2 always run the full
  matrix.
- cache-impact.yml: paths-ignore for clearly unrelated paths so the guard
  stops running on every PR event (~3k runs/month) when the PR can never
  trip it.
- release-desktop.yml: retention-days: 7 on the dist-* and preview-dist
  artifacts (same-run handoffs to the publish/mirror steps); they
  previously kept the 90-day default, accumulating ~17 GB of dead storage.

Verification:
- actionlint v1.7.7 passes on all three modified workflows (same version
  and -ignore flags as the lint job).
- Both modified YAML files parse cleanly.
- Simulated the path filter against 10 representative change sets
  (docs-only, root markdown, site-only, desktop-only, internal, npm-only,
  workers-only, workflow file, nested skill markdown, mixed); every output
  matched the intended gating.

* Speed up pull request CI: focused race, Windows smoke, push-only installer

Problem: after the required checks, merging waits on the slowest ones —
`race` averages ~12 min and `test (windows-latest)` ~6.3 min, while the
(non-required) desktop-windows job runs ~14.5 min, most of it building the
NSIS installer. Every PR pays that cost even though main-v2 re-verifies
everything after merge.

Change:
- race: pull requests run `go test -race` only on the concurrency-heavy
  packages (agent, plugin, jobs, proc, sandbox, filelock, eventwire,
  remote — the same areas the job comment calls out). Pushes to main-v2
  keep the full ./... sweep as the safety net. Check name is unchanged,
  so the required-checks ruleset is unaffected.
- test (windows-latest): pull requests run a smoke suite of the packages
  that actually carry *_windows.go code, plus cmd/. Full ./... sweep stays
  on pushes. The Unix legs are untouched.
- desktop-windows: Install NSIS + installer/portable build steps now run
  only on pushes; PRs stop after go test. Installer packaging is
  packaging validation, and the release pipeline rebuilds it regardless.
- codeql.yml: paths-ignore docs/**, release-notes/**, root *.md on push
  and pull_request (CodeQL checks are not required; the weekly cron keeps
  the full scan cadence).

Expected: required-check wait drops from ~12 min to ~5 min per PR, and
the all-green wait from ~15 min to ~6 min, with no loss of post-merge
coverage.

Verification:
- actionlint v1.7.7 passes on both workflows; YAML parses cleanly.
- `go list` resolves all packages in the race subset (24 pkgs) and the
  Windows smoke suite (37 pkgs).
- This PR's own checks exercise the new PR paths live: the race and
  windows-test jobs run their focused variants because the branch only
  touches workflow files.
2026-07-29 14:17:07 +08:00
SivanCola 36a2dee813 Gate CI jobs on changed paths to cut Actions waste (#7022)
Problem: every pull request runs the full ci.yml matrix (11 jobs across
3 OSes) and the cache-impact guard fires on every PR event, even for
docs-only or otherwise unrelated changes. That is roughly 33k wall-minutes
per month on GitHub-hosted runners — free while the repo is public, but
pure queue time now and a real bill if the repo ever goes private.

Change:
- ci.yml: add a cheap `changes` detection job and gate the heavy jobs on
  it. PRs confined to docs/, site/, release-notes/, workers/ or top-level
  Markdown skip the Go test/race/lint/govulncheck/coverage jobs; PRs that
  cannot affect the desktop module (which imports the root kernel via
  `replace reasonix => ../`) skip the desktop/desktop-macos/desktop-windows
  jobs; the site job only runs for site/ changes. Job-level `if` reports
  skipped, which still satisfies the required status checks (lint, race,
  test) — a workflow-level paths-ignore would leave them pending and block
  merges. Detection fails open, and pushes to main-v2 always run the full
  matrix.
- cache-impact.yml: paths-ignore for clearly unrelated paths so the guard
  stops running on every PR event (~3k runs/month) when the PR can never
  trip it.
- release-desktop.yml: retention-days: 7 on the dist-* and preview-dist
  artifacts (same-run handoffs to the publish/mirror steps); they
  previously kept the 90-day default, accumulating ~17 GB of dead storage.

Verification:
- actionlint v1.7.7 passes on all three modified workflows (same version
  and -ignore flags as the lint job).
- Both modified YAML files parse cleanly.
- Simulated the path filter against 10 representative change sets
  (docs-only, root markdown, site-only, desktop-only, internal, npm-only,
  workers-only, workflow file, nested skill markdown, mixed); every output
  matched the intended gating.
2026-07-29 13:52:29 +08:00
SivanCola c53448e02f Fix initial Goal Skill invocations / 修复目标模式首条消息无法调用 Skill (#6991)
* fix(desktop): allow skills in initial goal turns

Problem: Goal mode rejected or swallowed Skill invocations in the first user turn.

Root cause: The Composer blocked structured invocations before a Goal existed, while App wrapped the remaining first turn as /goal prose.

Fix: Require task text but preserve selected and pasted Skill invocations as structured submissions after activating the Goal. Extract the activation/submission order into a tested contract and add coverage for inline and subagent-compatible invocation paths.

Verification: pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx; pnpm exec tsx src/__tests__/send-failed.test.ts; pnpm typecheck; pnpm exec tsc --noEmit -p tsconfig.test.json; ./scripts/cache-guard.sh

* fix(desktop): scope initial Goal activation to source tab

Problem: Switching tabs while the first Goal was activating could store the Goal on the newly active tab while submitting the structured Skill to the original tab. Initial Goal setup also rejected attachment- or workspace-reference-only prompts after the Skill guard changed.

Root cause: The activation callback closed over activeTabId, and the Composer guard treated every empty-text first Goal as invalid even when non-text context was present.

Fix: Add explicit tab-scoped Goal/profile activation, route the initial structured submission through the source tab, preserve attachment and workspace-reference-only submissions, and cover the active-Goal Subagent path plus source-tab wiring.

Verification: go test ./internal/control -count=1; go test -race ./internal/control -count=1; go test -race ./internal/jobs -run TestStartForSession_AcceptsValidInput -count=1; pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx; pnpm exec tsx src/__tests__/send-failed.test.ts; pnpm typecheck; ./scripts/cache-guard.sh; git diff --check.

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

* fix(desktop): fail closed when initial Goal activation fails

Problem: Structured Skill submission on the first Goal turn could run even when SetGoalForTab failed or no-oped, because activation errors were swallowed and the structured path has no /goal prose fallback.

Root cause: setGoalForTab used .catch(() => {}), applyGoalForTab patched the local Goal profile before backend success, and regression coverage only asserted source wiring instead of deferred/bridge-failure behavior.

Fix: Propagate SetGoalForTab failures (still refresh meta in finally), patch the composer Goal profile only after backend activation succeeds, and document that activateGoalAndSubmit must abort send when activation fails. Add behavioral tests for bridge failure, deferred source-tab ordering, and attachment-/workspace-ref-only initial Goals.

Verification: pnpm exec tsx src/__tests__/send-failed.test.ts; pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx; pnpm typecheck.

* fix(desktop): return SetGoalForTab errors across the Wails boundary

Problem: Frontend fail-closed Goal activation still continued structured Skill submits when the real bridge "succeeded", because Go SetGoalForTab returned void and remote/invalid-tab failures only warned.

Root cause: The Wails method swallowed remote RPC errors and missing/replaced tabs with a normal return, so the Promise always resolved. Cross-tab coverage also only exercised helper callback order, not SetGoalForTab/SubmitInvocationsToTab tab ids through the controller bridge.

Fix: Make SetGoal/SetGoalForTab/ClearGoal/ClearGoalForTab return error (remote RPC, missing tab, replaced tab). Route the first Goal turn through activateGoalAndSubmitOnTab so source tab ids are captured once. Add desktop Go error tests and a controller bridge harness that hangs SetGoalForTab(A), switches to B, and asserts Goal plus SubmitInvocationsToTab stay on A.

Verification: cd desktop && go test -count=1 -run 'TestSetGoalForTab|TestClearGoalForTab|TestMetaReportsGoalStatus|TestCollaborationModesPreserveToolApprovalMode|TestToolApprovalModesPreserveCollaborationMode' .; pnpm --dir desktop/frontend exec tsx src/__tests__/goal-activation-tab-routing.test.tsx; pnpm --dir desktop/frontend exec tsx src/__tests__/send-failed.test.ts; pnpm --dir desktop/frontend typecheck.

* fix(desktop): handle Goal bridge rejections

Problem: Interactive and background Goal calls discarded rejected Wails promises, allowing unhandledrejection to reach the global crash overlay.

Root cause: Composer callbacks, mode shortcuts, model commands, and controller resync paths invoked asynchronous Goal actions without a shared error boundary.

Fix: Route non-submit Goal actions through a toast-aware rejection handler, await model commands, and defer local Goal state updates until bridge success while preserving fail-closed structured submissions.

Verification: pnpm frontend test/typechecks, focused Goal routing and rejection tests, git diff --check, and cache-guard.sh.

* fix(desktop): fence initial Goal submission to its target

Problem:
An initial Goal could activate on Remote tab A, then submit its structured Skill to Local tab A after the user switched projections. The routing regression also was not part of the standard frontend test suite.

Root cause:
Goal activation and the first turn crossed Wails as separate calls identified only by tab ID. Wails schedules calls independently, while Local and Remote projections can reuse the same tab ID.

Fix:
Capture the source tab and workbench target token in React, validate both in one backend operation, and hold the workbench transition lock across Goal activation and first-turn submission. Fail closed on stale targets, keep zero-valued request sequences in target events, and cover frontend bridge plus Go backend behavior.

Verification:
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- cd desktop && go test ./...
- cd desktop && go test -race -count=1 -run 'TestSubmitInitialGoal|TestWorkbenchTargetEventKeepsZeroRequestSequence|TestSetGoalForTab|TestClearGoalForTab|TestSetActiveTabSwitchesRemoteProjectionOnlyForDifferentTab' .
- cd desktop && go vet ./...
- ./scripts/cache-guard.sh
2026-07-29 09:37:40 +08:00
SivanColaandipanta 170b23fbde feat(desktop): add integrated terminal sessions (#6994)
* feat(desktop): add integrated terminal sessions

Add a right-dock xterm terminal with multi-session controls, workspace-relative start directories, and file-tree entry points. Use Unix PTY and Windows ConPTY backends with bounded session counts, output buffering, stale-tab checks, read-only and remote guards, and application shutdown cleanup. Keep shell resolution renderer-safe by accepting only backend-approved shell IDs and reading custom shell paths from user-global config.

Refs #6513
Refs #6891

Verification: desktop Go race tests, Windows amd64 cross-compile, frontend terminal tests, full frontend test suite, typecheck, CSS checks, and production build.

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

* feat(desktop): scope terminal to each chat tab

Align the integrated terminal with the chat-scoped Codex workflow. Bind sessions to the active chat tab and workspace, close only that tab's PTYs on tab close, and render the terminal as a responsive bottom drawer.

Expose a bounded terminal output snapshot through an explicit user action that adds it to the composer. Do not inject terminal output into provider prompts or bypass the existing agent shell approval path.

Verification: desktop terminal race tests, Windows amd64 cross-compile, frontend typecheck, terminal and workspace-layout tests, CSS checks, and production build.

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

* fix(desktop): harden integrated terminal lifecycle

Problem:
- Terminal output was lost after switching sessions or reopening the drawer, and concurrent session creation could overwrite renderer state.
- Closing a chat tab could race an in-flight terminal start, while Windows coverage did not exercise the real ConPTY path.
- The terminal drawer duplicated controls and loaded xterm into the initial application bundle.

Root cause:
- Output was buffered only while no sink was attached, state updates reused stale workspace snapshots, and the backend did not remember closed tab IDs.
- Terminal-specific frontend tests were outside the standard suite, and the Windows executable fixture lacked an .exe suffix.

Fix:
- Keep bounded per-session output history, clear it only when a session is removed, and merge concurrent session updates atomically.
- Tombstone closed tabs before process cleanup, reject late starts, suppress stale output, and distinguish natural exits from removals.
- Add backend-approved shell selection, compact horizontal session tabs, lazy terminal loading, Windows ConPTY coverage, and CI wiring.

Verification:
- Desktop terminal race tests, full desktop tests, and go vet pass.
- Windows amd64 test-binary cross-compilation passes.
- Full frontend tests, TypeScript type checking, and production bundle checks pass.

* fix(desktop): close integrated terminal review gaps

Problem:

- Read-only transitions and workspace rebinding could leave existing shells alive or allow a slow shell start to register against stale tab state.
- First-open, retry, concurrent create, and single-session UI states could lose errors, duplicate requests, or hide the only close control.
- Terminal output added to chat could retain terminal control payloads or break its Markdown fence.

Root cause:

- Terminal lifecycle invalidation was not serialized with tab capability changes, and process exit did not wait for the output reader to drain.
- Renderer readiness used replace-style state updates without a retryable error model.
- Composer insertion treated raw terminal output as ordinary text.

Fix:

- Add per-tab generations, capability gates, process-output draining, process-group cleanup coverage, and macOS PTY CI.
- Make terminal readiness deduplicated and retryable, preserve concurrent sessions, expose operation errors, and keep bottom-drawer controls usable in all layouts.
- Strip ANSI, OSC, C1, and unsafe control sequences, bound inserted output, and choose a safe Markdown fence.

Verification:

- go test ./... and go vet ./... in desktop/
- go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' . in desktop/
- Windows amd64 and arm64 cross-builds for desktop/
- pnpm test, test:typecheck, test:remote, test:updater, test:performance, typecheck, test:terminal, and build
- go vet ./..., gofmt, YAML parse, and git diff --check

---------

Co-authored-by: ipanta <[email protected]>
2026-07-29 08:29:28 +08:00
32721fc0e1 feat(context): introduce Context Engine v2 / 引入上下文引擎 v2 (#7001)
* feat(memory): separate standing instructions from scoped facts

* fix(memory): preserve scoped memory compatibility

Problem:
Omitted-scope updates could move legacy global memories into the current project, and global preferences or feedback were unavailable on the first turn. Mixed-version clients could also route independently scoped facts through the wrong directory.

Root cause:
Save normalized an empty scope before consulting the active file, the stable prompt only contained the memory index, and previous releases treated metadata.type as the routing signal.

Fix:
Preserve the effective scope of same-name updates, snapshot global user and feedback bodies into deterministic lower-priority guidance, retain a previous-release routing type alongside authoritative fact_type metadata, and queue complete save/revocation notes for the current session.

Verification:
./scripts/cache-guard.sh
go vet ./...
env -u DEEPSEEK_API_KEY go test ./...
(cd desktop) go test ./...
(cd desktop/frontend) pnpm test
(cd desktop/frontend) pnpm build
gofmt -l .
git diff --check

* refactor(context): separate raw and provider user input

* refactor(instructions): resolve scoped context with provenance

* refactor(memory): add versioned fact storage

Context: Memory facts were addressed by mutable slugs, trusted a separately maintained index, and had no durable update history or conflict protection.

Change: Add stable IDs, monotonic revisions, atomic writes, derived indexes, deterministic legacy migration, scoped identity handling, revision restore, and optimistic update checks. Surface the metadata through tools and Desktop while preserving legacy frontmatter routing and Safe Mode behavior.

Compatibility: Existing files remain readable, gain deterministic runtime metadata before migration, and keep metadata.type/fact_type/scope values that previous releases understand. Added Desktop JSON fields are optional.

Verification: go test ./internal/memory; go test -race ./internal/memory; go test ./internal/boot -run Test.*Memory; go test ./... with provider credentials isolated; desktop go test ./...; frontend pnpm test and typecheck; go vet ./...; ./scripts/cache-guard.sh.

* feat(memory): recall relevant facts per user turn

Problem:
Saved facts were only visible through a stable index and required the model or user to invoke memory search manually.

Design:
Add conservative host-side BM25 recall for real user turns with project-over-global deduplication, freshness labels, explainable scoring, strict character limits, and local-home redaction. Keep the system prompt and tool schemas stable by appending recall only to the provider user-turn tail, and exclude synthetic turns and already-loaded global guidance.

Compatibility:
Preserve Store.List behavior and add ListAll for scope-aware consumers. No persisted memory or session fields change; display fallbacks strip the new transient suffix.

Verification:
go test -count=1 ./internal/memory ./internal/agent ./internal/control
go test -race -count=1 ./internal/memory
go test -race -count=1 ./internal/control with focused recall tests
go test -count=1 ./internal/tool/builtin/ ./internal/boot/
go vet ./...
./scripts/cache-guard.sh

* feat(memory): auto-save low-risk project facts

Problem:
Every remember call required a fresh approval, including bounded project facts that were new, non-sensitive, and low impact. This made durable context expensive enough that agents and users often skipped it.

Design:
Classify interactive remember requests against the scoped store. Automatically allow only explicit project/reference creates that are bounded, non-sensitive, and not semantic duplicates. Keep global, user/feedback, sensitive, and overwrite requests on the existing fresh-human path; keep headless and sub-agent mutation fail closed.

Safety:
Issue one-shot hashed grants and enforce create-only again inside the serialized Store write, preventing an approval-to-write race from becoming an overwrite. Clear abandoned grants at turn completion. Expose revision history and audited restore through the controller.

Compatibility:
The remember description and schema are unchanged. RequireCreate is a runtime-only SaveOptions field; persisted memory and session formats are unchanged.

Verification:
DEEPSEEK_API_KEY= go test -count=1 ./...
go test -count=1 ./... in desktop
go test -race -count=1 ./internal/memory ./internal/control with memory/approval tests
go vet ./...
./scripts/cache-guard.sh

* feat(memory): recover archived facts safely

Problem: Archived facts remained inspectable on disk but users could not recover them through Reasonix, and ad hoc restoration could overwrite active memory or lose revision provenance.

Fix: Add store-validated archive recovery with collision checks, exclusive active-file creation, monotonic revisions, revision snapshots, controller turn-tail refresh, and a Desktop restore action.

Compatibility: Existing active and archived Markdown files remain readable; recovery writes the established v2 frontmatter and hidden revision layout, while older binaries continue to read the restored active fact.

Verification: go test ./internal/memory ./internal/control; go test ./... in desktop; pnpm test; pnpm exec tsc --noEmit; ./scripts/cache-guard.sh.

* feat(context): expose memory recall diagnostics

Expose scoped fact freshness, project-over-global conflict decisions, and the last automatic recall trace through the desktop contract. Add revision history and restore APIs while preserving non-null collection responses and remote-workspace isolation.

This gives Context Center enough provenance to explain what was available, selected, suppressed, or restored without changing the stable prompt prefix.

* feat(context): add provenance center to desktop

Make scoped memories explainable in the desktop settings surface. Show stable identity, freshness, revision history, project-over-global resolution, automatic recall traces, instruction precedence, imports, and diagnostics.

The suggestions workflow remains compatible while the Context Center gains zero-impact read paths and explicit restore actions for user-controlled recovery.

* feat(memory): make suggestions zero-config

Open the suggestions surface with automatic local-history scanning by default, removing the hidden localStorage setup switch. Keep every candidate behind explicit confirmation.

Deduplicate against all scoped active facts and resolved instruction bodies so project and global memories cannot produce repeated guidance.

* feat(memory): add CLI diagnostics and recovery

Context:
Context Engine v2 exposed revision, recall, and instruction provenance in the backend and desktop, but the CLI only listed legacy merged facts and archives.

Changes:
- add shared /memory diagnostics for scoped facts, recall traces, instruction precedence, revisions, and archives
- add explicit revision restore and archive recovery commands with structured completion across CLI, desktop, and remote workspaces
- preserve same-name facts across scopes and keep narrow TUI output bounded

Verification:
- go test ./internal/control ./internal/cli ./internal/remote/workbench/runtime ./internal/memory -count=1
- go test -race ./internal/control ./internal/memory
- cd desktop && go test . -count=1
- ./scripts/cache-guard.sh

Cache-impact: management diagnostics only; provider-visible prompt and tool schemas are unchanged.

* docs(context): document instructions and memory v2

Context:
Context Engine v2 changed instruction precedence, automatic recall, safe memory writes, revision recovery, and desktop suggestions, while the user docs still described the legacy approval-only retrieval model.

Changes:
- rewrite the context and memory contract around standing instructions versus background facts
- document recall freshness, project-over-global resolution, zero-configuration writes and suggestions, revision/archive recovery, cache stability, privacy, and remote fail-closed behavior
- add a complete Chinese companion and update Guide, CLI, migration, spec, approval, and README navigation in both languages

Verification:
- validated relative Markdown links, code fences, and final newlines across 14 changed files
- scanned for stale approval/suggestion claims
- git diff --cached --check
- scanned the staged diff for secrets and machine-local paths

* fix(context): preserve safe headless memory writes

Allow controller-scoped low-risk project/reference creates to pass the non-interactive approval gate while keeping deny rules and all higher-risk memory operations fail-closed. Reject archive recovery through symlinked files or archive directories, with end-to-end regressions for both boundaries.

* fix(memory): reject non-directory migration roots

* fix(context): make instruction and memory references safe

Problem:
Provider-visible instruction paths exposed machine-local roots, memory results exposed store paths, and listed scoped references could not be used directly for read, update, revision, or archive operations.

Root cause:
Instruction rendering used absolute source paths and memory lookup treated display names as unqualified slugs. Symlink reads also crossed boundaries through a path-resolution gap, while scope-aware listing deduplicated same-name facts.

Fix:
Render stable workspace/user provenance, confine instruction and import reads to their owning roots, reject external symlinks, and support project/<name>.md and global/<name>.md references end to end. Preserve unqualified compatibility, stable-ID renames, revision snapshots, exact-scope archive behavior, low-risk create-only approval boundaries, and bilingual documentation.

Verification:
Focused instruction/memory tests and race tests; go test -count=1 ./...; go test -race ./...; go vet ./...; desktop go test/race/vet; pnpm test:all; pnpm build; go run ./cmd/remote-protocol-gen --check; ./scripts/cache-guard.sh (8/8 pass, long tool loop tail average 95%); staged privacy scan clean.

* docs(contributors): attribute adopted context work

Attribution:
Record the authors whose prior pull request mechanisms were adapted by the Context Engine v2 integration.

- cyq1017: feedback injection from #5710 and Markdown input normalization from #5603
- salarkhannn: Markdown input normalization from #5604

Scope:
This metadata-only commit adds repository-level co-author attribution without changing runtime behavior.

Verification:
Confirmed source PR ownership and both public GitHub noreply identities.

Co-authored-by: cyq <[email protected]>
Co-authored-by: Salar <[email protected]>

* test(desktop): allow slow Windows git metadata probe

Problem:
The Windows Desktop CI job failed when MetaForTab's asynchronous first git probe did not finish within the test's one-second deadline.

Root cause:
The production path intentionally launches the cold git branch lookup in the background so tab metadata never blocks. Windows runner process startup can exceed one second under normal CI load, making the test timing assumption platform-fragile.

Fix:
Keep the non-blocking metadata implementation unchanged and extend the cross-platform test observation window to five seconds, with a comment documenting the Windows process-startup variance.

Verification:
- GOCACHE=/tmp/reasonix-desktop-go-cache go test -count=1 -run 'TestMetaForTabIncludesWorkspaceContext|TestWorkspaceGitBranch' ./desktop
- git diff --check

---------

Co-authored-by: cyq <[email protected]>
Co-authored-by: Salar <[email protected]>
2026-07-29 02:27:29 +08:00
PaulDing98andSivanCola 241d4f5250 fix(cli): harden keyless default model fallback / 修复 CLI 无密钥默认模型回退 (#7002)
* fix(cli): fall through keyless default_model to next configured provider

The CLI built sessions on whatever cfg.DefaultModel said, even when that
provider had no API key in the current environment. Every command
(
easonix -p, 
easonix run, 
easonix review, 
easonix acp,
subagent runs) then hard-failed with 'missing env X_API_KEY' until the
user manually picked a different model in the bottom switcher, even
when another configured provider was sitting right there (#6996).

A companion fix to desktopNewSessionModel in #6999, which only covered
the desktop NewSession path. This change covers the shared boot.Build
entry point used by every CLI subcommand plus the six pre-Build call
sites that resolve the bootstrap / banner / effort / ACP model ref.

Semantics:
  - No explicit --model: use cfg.DefaultModel when configured, else
    fall through to the first provider that has a configured key. When
    nothing is configured the raw default is preserved so the existing
    'unknown model' / 'missing env' messages still surface.
  - Explicit --model (or session-level p.Model / f.model): strict
    resolve, fail loud on keyless or unknown. We must never silently
    reroute a model the user asked for by name.

Verification:
  - go test -count=1 ./internal/cli/... ./internal/boot/... ./internal/config/...
  - go vet ./...
  - gofmt -l internal/cli/ internal/boot/
  - manual: a config with keyless default_model + a configured second
    provider now boots onto the second provider (the prior build
    hard-failed on the keyless one).
  - manual: --model <keyless_ref> still errors; --model <configured_ref>
    still works.

Cache-impact: none - CLI/boot model selection only, no provider-visible
prompt, tool schema, memory prefix, or cache key touched.
Cache-guard: TestBuildKeylessDefaultFallsBackToConfiguredProvider +
TestBuildExplicitKeylessModelStillFails in internal/boot, plus the
8-case TestResolveModelForCLI table in internal/cli/default_model_test.
System-prompt-review: N/A.

* fix(cli): preserve explicit model in TUI state

Keep the controller's resolved model reference as the TUI source of truth so interactive keyless recovery and later controller rebuilds never reinterpret an explicit model as an implicit default. Add a regression covering an explicit keyless model with the interactive RequireKey=false path.\n\nCo-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-29 00:44:43 +08:00
SivanCola 111497a9b5 fix(agent): stop stale edit retry loops across Goal continuations / 阻止目标模式陈旧编辑重试死循环 (#7012)
* fix(agent): stop stale edit retry loops

Problem:
Goal mode could alternate successful file reads with repeated stale edit_file or multi_edit calls, bypassing the existing consecutive-failure guard and wasting automatic continuation turns.

Root cause:
The host treated any successful read as progress and had no goal-scoped, target-aware memory of failed write intents.

Fix:
Track semantic failed-write signatures across one user turn or stable Goal scope, invalidate them only for overlapping mutations, and re-check stale-anchor applicability through the existing side-effect-free preview before blocking a retry.

Verification:
- go test ./internal/agent -run 'TestRepeatGuard' -count=1
- go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- ./scripts/cache-guard.sh

* fix(agent): retain stale edit failures across file changes

Problem:
Repeated stale anchor failures could regain retry budget after another successful edit to the same file or after changing the target path spelling.

Root cause:
The Goal-scoped guard cleared every overlapping failure record, keyed signatures with raw paths, and treated dynamic nearest-line diagnostics as the error class.

Fix:
Preserve Preview-recheckable anchor failures across mutations, normalize anchor error classes and target paths, and cover real edit_file state changes plus path aliases.

Verification:
- go test ./internal/agent -run 'TestRepeatGuard' -count=1
- go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./...
- ./scripts/cache-guard.sh

* fix(agent): reset recoverable failures between goal runs

Problem:
Ordinary write failures were retained across Run calls in the same Goal scope, so a recovered permission or external-state issue could be blocked before retry execution.

Root cause:
The Goal-scoped repeat-failure map preserved every write-like failure even though only stale-anchor failures have a side-effect-free state recheck.

Fix:
Keep stale-anchor failures across Runs and discard ordinary write failures at the next Run boundary. Add a regression test proving the write executes again in each Run.

Verification:
go test ./internal/agent -run 'TestRepeatGuard' -count=1
go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
go test ./internal/control -run 'TestRecoveryPauseKeepsGoalRunningAndDeliveryScope|TestGoalReadinessFailureBlocksAndKeepsDeliveryScope' -count=1
go vet ./...
env -u DEEPSEEK_API_KEY go test ./... -count=1
./scripts/cache-guard.sh

* fix(agent): tailor repeated write failure guidance

Problem:
The repeat-failure guard used stale-anchor recovery instructions for every repeated write failure, including permission and filesystem errors.

Root cause:
The block message did not distinguish Preview-recheckable anchor failures from ordinary write failures even though the failure record already carried that classification.

Fix:
Keep anchor-specific guidance for stale edit failures and return generic write-condition guidance for ordinary failures. Extend the permission-denied regression test to reject stale-anchor wording.

Verification:
go test ./internal/agent -run 'TestRepeatGuard' -count=20
go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
go test ./internal/control -run 'TestRecoveryPauseKeepsGoalRunningAndDeliveryScope|TestGoalReadinessFailureBlocksAndKeepsDeliveryScope' -count=10
go test ./internal/agent -count=1
GOCACHE=/private/tmp/reasonix-go-cache-vet2 go vet ./internal/agent
./scripts/cache-guard.sh
2026-07-28 16:42:15 +00:00
par73eandSivanCola 4ef8971afd fix(desktop): allow skill invocation anywhere in the composer (#6964)
* fix(desktop): allow skill invocation anywhere in composer

* Fix composer invocation interaction regressions

Problem:
Composer invocations could clip WebView2-hosted text, leave command suffixes when selected mid-token, lose pointer-accessible removal, and retain stale slash completion state across draft switches.

Root cause:
The caret anchor forced a one-pixel hidden box, slash replacement ranges stopped at the caret, the invocation badge remove callback was dropped, and draft restoration updated only the selection ref.

Fix:
Allow caret anchors to expand, replace complete slash tokens, restore accessible badge removal, synchronize restored plain selections, and cover each behavior with focused regressions.

Verification:
- rich-composer-selection: 99 passed
- composer-goal-toggle: 195 passed
- composer resized input CSS: 7 passed
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- pnpm build
- browser computed-style check
- git diff --check

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

* Restore rich slash queries across session switches

Problem:
Rich composer drafts restored their text and invocation entities after a session switch but could leave slash completion closed. Deferred caret work from an earlier activation could also overwrite the restored selection after a rapid A-to-B-to-A switch.

Root cause:
Draft restoration synchronized only the plain textarea selection, and composer animation-frame callbacks were not scoped to a specific draft activation.

Fix:
Restore both selection models, recompute rich slash completion when invocation entities are present, and discard deferred selection or focus work once the draft activation changes. Cover deterministic stale-frame draining, A-to-B-to-A isolation, and second-skill selection.

Verification:
- composer goal toggle: 200 passed
- rich composer selection: 99 passed
- composer resized input CSS: 7 passed
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- pnpm build
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-29 00:26:28 +08:00
SivanCola 26b256b5d5 refactor(agent): simplify run/tool state and harden live tests / 重构 Agent 状态并隔离真实测试 (#7010)
* refactor(agent): add TaskTool Options constructor / 增加 TaskTool Options 构造入口

Problem:
NewTaskTool takes 18 positional parameters, which is hard to extend and
easy to mis-order at call sites.

Root cause:
Construction was only exposed as a long positional helper with no
structured options type.

Fix:
- Add TaskToolOptions and NewTaskToolWithOptions as the internal standard
  entry point (empty SysPrompt still maps to DefaultTaskSystemPrompt).
- Keep the 18-arg NewTaskTool as a compatibility wrapper for one full
  iteration cycle.
- Migrate boot wiring and the shared test helper to Options.
- Add equivalence tests for config, schema, empty prompt, resolver, and
  execute behavior.

Verification:
go test ./internal/agent -run 'TestNewTaskToolWithOptions|TestTaskTool' -count=1
go test ./internal/boot -count=1

* refactor(agent): split Run and executeOne state machines / 拆分 Run 与 executeOne 状态机

Problem:
Agent.Run and executeOne mixed lifecycle, turn policy, and tool policy in
two very large methods, raising cognitive load without changing behavior.

Root cause:
Turn-loop counters and tool-call gating lived inline instead of package-
private staged helpers with a single ownership model for locks/defers.

Fix:
- Keep Run as the lifecycle owner of workspace lease, evidence commit,
  delivery checkpoint, steer queue, and active-turn cleanup.
- Extract runLoopState plus beginRunTurn, runToolLoop, handleFinalResponse,
  and handleToolRound without changing stream/persist/batch/compact order.
- Split executeOne into parseToolCall, resolveToolPolicy (plan/proxy,
  delivery, recovery/permission), prepareToolExecution, and
  finishToolExecution behind toolCallPlan, preserving permission-before-
  lease and PreToolUse-before-execute semantics.

Verification:
go test ./internal/agent/... -count=1
go test -race ./internal/agent/... -count=1
REASONIX_RELEASE_CACHE_GUARD=1 go test ./internal/agent -run '^TestReleaseCacheHitGuard$' -count=1

* ci: report-only agent complexity monitor / 增加 agent 复杂度报告模式监控

Problem:
The repo already runs gofmt, vet, build, full tests, race, and
golangci-lint, but had no trend signal for remaining internal/agent
hotspots after structured extraction.

Root cause:
Main lint intentionally avoids funlen/cyclop hard fails that would
generate a flood of pre-existing noise.

Fix:
- Add a report-only funlen+cyclop config scoped to internal/agent
  production code with generous thresholds (not a hard 50-line rule).
- Run it beside the existing lint job with issues-exit-code=0, write a
  CI summary, and upload the report as an artifact.
- Ignore the generated local report file.

Verification:
bash scripts/agent-complexity-report.sh

* test(provider): isolate live DeepSeek cache probe / 隔离 DeepSeek 真实缓存探针

Problem:
The environment-gated DeepSeek cache probe ran during ordinary test suites whenever a stale API key was present, and its diagnostics still claimed tool-call reasoning replay should have zero prompt-token cost.

Root cause:
The probe had no explicit live build tag and retained commentary from the obsolete policy that stripped reasoning_content from DeepSeek tool-call turns.

Fix:
Gate the probe behind the live build tag, document the tagged invocation, describe required replay versus the empty-key recovery fallback, and fail when the required replay request is rejected.

Verification:
- go test ./...
- go test -race ./internal/provider/openai -count=1
- env -u DEEPSEEK_API_KEY go test -tags live ./internal/provider/openai -run TestRealDeepSeekCacheProbe -count=1 -v
- go vet ./internal/provider/openai
- ./scripts/cache-guard.sh
2026-07-28 23:16:58 +08:00
SivanCola 87dcb73114 fix(desktop): prevent freezes in large sessions / 修复大型会话卡顿 (#6993)
* fix(desktop): prevent freezes in large sessions

Problem:
Large session collections, rapid profile/model changes, and long streaming responses could trigger repeated filesystem scans, redundant controller rebuilds, and expensive transcript-wide rerenders. Remote Profile and Goal persistence could also diverge if the process stopped between durable writes.

Root cause:
Project-tree refreshes invalidated a global cache, profile axes crossed independent asynchronous calls, model changes queued every intermediate selection, and streaming Markdown reparsed the growing document on every delta. Remote session registry and Goal sidecar updates had no recovery journal.

Fix:
Add directory-scoped cache generations with TTL reconciliation, serialized atomic composer profiles, coalesced model switching, event-driven tab metadata refresh, an external live-stream store, batched stable Markdown rendering, lazy frontend chunks, and bundle/performance budgets. Preserve empty-history Goal state and make remote Profile plus Goal updates crash-recoverable with a durable transaction journal.

Verification:
- go test -race ./... in root and desktop modules
- focused cache, Profile, Goal, and model lifecycle race tests repeated 10 times
- pnpm test:all and production frontend build
- go vet ./... in root and desktop modules
- protocol generation drift and cache guard checks
- Windows amd64 and Linux amd64 cross-compilation checks

* fix(desktop): eliminate metadata and task rollback races

Problem:
Concurrent AutoResearch task creation could select the same directory ID across Store instances, and a failed durable Goal rollback could remove another transaction's task. Tab metadata refreshes could also apply a snapshot begun before a tab mutation.

Root cause:
Task IDs were chosen by a check-then-create sequence and deletion had no ownership proof. The bounded tab refresh coordinator treated a saturated in-flight request as current even after a backend mutation.

Fix:
Reserve task directories with atomic Mkdir and attach an opaque creation token required for rollback deletion. Add mutation generations and a latest-only trailing refresh to the tab metadata coordinator, then use it for lifecycle and explicit tab mutations.

Verification:
- go test ./internal/autoresearch
- go test -race ./internal/autoresearch
- go test -race ./internal/control ./internal/remote/workbench/runtime
- go test ./... (desktop module)
- pnpm --dir desktop/frontend test:all
- go vet ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime

* fix(remote): recover AutoResearch profile side effects

Problem:
Prepared Profile+Goal recovery restored the session registry and Goal sidecar but could leave a newly-created AutoResearch task running after a crash.

Root cause:
The task ownership token was generated inside task creation and was not durable in the parent transaction before the task directory appeared.

Fix:
Persist a transaction-owned create token in the prepared journal, bind transaction task IDs to a hash of that token, and remove the uniquely-owned task during prepared rollback and restart recovery. Preserve committed tasks and cover incomplete directory reservations.

Verification:
- go test ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime
- go test -race ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime
- go vet ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime

* ci: retry final baseline checks
2026-07-28 23:14:02 +08:00
SivanCola fe2ad317fa fix(desktop): resolve eligible defaults for new sessions / 为桌面新会话解析可用默认模型 (#6999)
Fixes #6996
2026-07-28 17:48:04 +08:00
0efda3bb9f fix(desktop): clear stale session data when reusing blank tab / 修复新建会话时展示旧工具调用记录的问题 (#6909)
* fix(desktop): clear stale session data when reusing blank tab / 修复新建会话时展示旧工具调用记录的问题

When creating a new session tab via handleNewTab → openBlankSession → ensureBlankTab, the backend may reuse an existing blank tab that still carries stale session data (e.g. tool calls from a previous session). The frontend then loads this stale data via HistoryPageForTab, rendering 'N 个工具' tool-fold cards that should not appear in a fresh session.

The fix calls app.NewSessionForTab(meta.id) after EnsureBlankTab returns, mirroring what newSession() already does, to guarantee the backend session is clean before loadSessionDataForTab runs. A silent catch with addBreadcrumb preserves diagnostic signal when the backend refuses.

Cache-impact: none — only desktop/frontend TypeScript, no cache-sensitive internal paths.

* Fix reused blank tab hydration

Problem:
Reusing an existing blank tab could leave stale tool cards visible and allow an earlier history request to repopulate them.

Root cause:
The frontend kept the existing tab state and used a non-reset hydration. The added NewSessionForTab call did not invalidate local requests and could no-op for an already blank backend session.

Fix:
Remove the redundant backend session rotation, invalidate checkpoint work, and force a reset hydration bound to the TabMeta session path.

Verification:
- pnpm exec tsx src/__tests__/new-session-load-race.test.tsx
- pnpm exec tsc --noEmit
- cd desktop && go test ./...
- git diff --check

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

* Add reused blank tab hydration regression

Contract:
A reused blank tab starts a fresh history load, clears orphaned tool cards immediately, and rejects late history from the previous session.

Coverage:
Add an integration-style useController test that holds the startup history request, reuses the same tab id with a new session path, and releases the stale tool history after the fresh empty hydration completes.

Verification:
- pnpm exec tsx src/__tests__/new-session-load-race.test.tsx
- pnpm exec tsc --noEmit
- git diff --check

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

* Fix stale desktop navigation completions

Problem:
A tab click made while blank, topic, or session navigation was in flight could be overwritten by the older completion. The frontend could show an abandoned tab or disagree with the backend active tab.

Root cause:
Navigation and tab-switch queues tracked last-click ownership independently, and controller navigation helpers advanced their epoch only when execution began. A queued click therefore did not invalidate an already-running operation, while late metadata and hydration work could still apply.

Fix:
Capture one shared navigation intent at enqueue time, propagate it through composite controller operations, and reject stale UI, hydration, and metadata updates. When a stale backend navigation already activated its result, reassert the currently visible tab so frontend and backend focus converge.

Verification:
- new-session-load-race: 28 passed
- tab-switch-hydration: 75 passed
- activate-topic-stale: 8 passed
- open-topic-coalescing: 15 passed
- app-chrome-tabs: 83 passed
- pnpm exec tsc --noEmit
- git diff --check

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

* Fix stale navigation reassertion races

Problem:
A stale tab switch or fork repair could finish after a third navigation and leave the backend active tab behind the visible frontend tab.

Root cause:
Both stale-completion paths captured the visible tab once and issued a single SetActiveTab call without rechecking ownership after the RPC returned.

Fix:
Route stale switch and fork completions through the shared reassert loop so each await is followed by a fresh visible-tab check.

Verification:
- pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx
- focused desktop frontend navigation matrix
- pnpm exec tsc --noEmit
- git diff --check

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

* Add stale navigation repair regressions

Contract:
A stale tab-switch or fork repair must keep following navigation ownership when a third navigation completes while SetActiveTab is still pending.

Coverage:
Use deferred backend gates to force both three-way interleavings and assert that the latest tab remains visible and backend-active.

Verification:
- pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx (79 passed)
- focused desktop frontend navigation matrix (409 passed)
- pnpm exec tsc --noEmit
- git diff --check

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

---------

Co-authored-by: xiaoshuai1024 <[email protected]>
Co-authored-by: Sivan <[email protected]>
2026-07-28 06:19:02 +00:00
PaulDing98andSivanCola 171343542e fix(jobs): preserve trusted session path semantics / 保持可信 session 路径语义 (#6937)
* fix(jobs): reject traversal in SetActiveSessionPath sessionPath

Follow-up to the issue #6932 thread (the StartForSession half is fixed in
#6936). SetActiveSessionPath trimmed sessionPath but did not reject `..`
components. Combined with store.SessionJobsDir(sessionPath) (which builds
<id>.jobs from the transcript path), a malicious or malformed sessionPath
like "[tmp][..][sibling][passwd.jsonl]" would resolve to a sibling
sidecar directory and then flow into:

  * m.artifactDirs[parentSession] = newDir
  * migrateArtifactDirForSession (file copies to the caller-picked dir)
  * loadSessionArtifacts (reads meta files from the caller-picked dir)

This change:

  * Adds validateSessionPath, which rejects empty input, NUL and control
    characters, and any path component equal to "..". Unlike
    validatePathSegment, separators are allowed because sessionPath is an
    absolute or workspace-relative path. Names like "..hidden" and "..."
    remain accepted as legitimate filenames; only ".." as a standalone
    component is rejected.

  * Calls validateSessionPath at SetActiveSessionPath entry, after trim
    and before any lock or filesystem side effect. A failure emits a
    Warning notice through the manager's sink (so the caller can observe
    the rejection in its event stream) and returns immediately without
    touching m.active, m.artifactDirs, or the migration/load paths.

  * Adds jobs_sessionpath_test.go with a 15-case table for
    validateSessionPath, plus three integration tests:
      - TestSetActiveSessionPath_RejectsTraversalPayload: a payload
        containing ".." does not create a sidecar directory next to a
        canary file, leaves m.artifactDirs empty, and emits the expected
        warning.
      - TestSetActiveSessionPath_RejectsLeadingDotDot: a leading ".."
        component is rejected the same way.
      - TestSetActiveSessionPath_AcceptsValidInput: a typical
        t.TempDir()/abc.jsonl input still flows through to
        store.SessionJobsDir, caches correctly, and a follow-up
        StartForSession writes the artifact next to the transcript.

This PR is independent of #6936: validateSessionPath does not call
validatePathSegment, so the two can be reviewed and merged in either
order. Both fixes share the underlying issue #6932 threat model.

* fix(jobs): validate session paths across platforms

Problem: SetActiveSessionPath traversal validation missed Windows-style backslash components on Unix and changed the legacy empty-path behavior.

Root cause: filepath.ToSlash only rewrites the current platform separator, and validation ran before the active-only empty-path branch.

Fix: Normalize both separator styles explicitly, keep empty paths as active-only calls, document the trusted-path boundary, and add regression coverage.

Verification: go vet ./internal/jobs/...; go test -count=1 ./internal/jobs/...; go test -race -count=1 ./internal/jobs/...; go test -count=1 ./...

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

* fix(jobs): preserve trusted session path semantics

Problem: Trusted transcript paths containing valid .. or host-specific separator characters were rejected, and rejection left active-session routing and persistent bindings stale.

Root cause: The validator conflated lexical path syntax with trusted-root containment and returned before updating independent session-routing state.

Fix: Limit trusted-path validation to control characters, preserve the caller path spelling, update active routing on rejection, clear stale bindings, and add regression coverage for relative paths and invalid rebinding.

Verification: go vet ./internal/jobs/...; go test -count=1 ./internal/jobs/...; go test -race -count=1 ./internal/jobs/...

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-28 13:45:47 +08:00
2fefeef06f feat: add regional CNY/USD pricing and English localization / 增加区域人民币/美元价格与英文国际化 (#6945)
* feat(desktop): add regional pricing and English localization

Problem: Desktop cost estimates assumed a single CNY price table, while automatic session titles and several desktop options remained Chinese-only for English users.

Root cause: Currency selection was coupled to persisted language settings, browser locale never reached controller rebuilds, cost telemetry lacked consistent regional repricing, and fallback copy was not localized at the API boundary.

Fix: Add official CNY/USD regional pricing with auto locale detection and an explicit desktop override, preserve structured currency telemetry and custom prices, localize session titles and missing desktop options, and make generated titles follow the user's language.

Verification: Passed config/boot/serve and full desktop Go suites, frontend typecheck and production build, focused frontend behavior tests, the cache guard, and git diff checks.

* fix: preserve regional pricing and manual topic titles

Problem:
Auto regional pricing could overwrite persisted official price tables, and manually entered default-like topic titles were localized.

Root cause:
Pricing normalization did not retain TOML provenance, while title localization inferred automation from title text alone.

Fix:
Carry regional pricing provenance through config loading, normalization, backfill, and edit replay; only explicit currency choices override persisted tables. Propagate topic title source through desktop runtimes and localize only auto-generated default titles.

Verification:
go test ./internal/config ./internal/boot ./internal/serve -count=1
cd desktop && go test ./... -count=1

* chore: credit regional localization contributors

Context: PR #6945 consolidates regional pricing preservation and English session-title work from three related contributor PRs.

Attribution: Record the source authors with their public GitHub noreply identities so the consolidated repository history preserves their contributions.

Verification: Attribution-only commit; product files are unchanged.

Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: erphenheimer <[email protected]>
Co-authored-by: delight-f <[email protected]>

* feat(cli): add regional pricing controls

Context: PR #6945 already provides regional DeepSeek pricing in desktop, but CLI startup, in-session settings, and JSON result output did not expose the same currency contract.

Changes: Resolve Auto pricing from the active CLI locale, add global currency config and slash commands, refresh the current controller after language or currency changes, and emit currency-aware total_cost while retaining total_cost_usd as a compatibility alias. Reject mixed-currency totals and prevent project config from overriding user regional preferences.

Verification: go test ./internal/cli ./internal/config ./internal/boot ./internal/control ./internal/i18n -count=1; go test ./... -count=1 with DEEPSEEK_API_KEY unset.

* docs(cli): document regional pricing controls

Documentation:
- Add English and Chinese examples for global currency selection and locale-based Auto.
- Explain structured cost/currency output and the total_cost_usd compatibility alias.
- List the /currency session command and runtime refresh behavior.

Verification:
- go test ./internal/cli -run "Test(ConfigCurrency|RunOutput)" -count=1
- git diff --check

---------

Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: erphenheimer <[email protected]>
Co-authored-by: delight-f <[email protected]>
2026-07-28 13:45:15 +08:00
SivanCola b907a6b097 Add privacy-preserving CLI telemetry and crash reports / 添加隐私友好的 CLI 遥测与崩溃报告 (#6984)
* feat(cli): add privacy-preserving telemetry and crash reports

Summary:
- add bounded CLI usage metrics with explicit policy controls and silent retry behavior
- capture sanitized panic reports locally and require user confirmation before upload
- separate Desktop and CLI aggregates in the crash-report worker and stats dashboard
- document telemetry fields, privacy guarantees, report commands, and D1 migration requirements

Compatibility:
- default missing client surfaces to Desktop for existing clients and payloads
- migrate existing aggregate rows to the Desktop surface before deploying the new worker

Verification:
- go test ./internal/telemetry ./internal/crashreport ./internal/config ./internal/cli ./cmd/reasonix
- env -u DEEPSEEK_API_KEY go test ./...
- pnpm test (workers/crash-report, 49 tests)
- pnpm run typecheck (workers/crash-report)
- ./scripts/cache-guard.sh

* fix(stats): decouple CLI schema migration from Worker deploy

Problem:
The initial surface migration rebuilt the existing Desktop telemetry tables and changed their primary keys. Running it before the new Worker broke the released Worker's ON CONFLICT clauses, while deploying the Worker first queried columns that did not exist yet.

Root cause:
Desktop and CLI isolation was implemented by changing a shared persisted schema, which made the old and new Worker contracts mutually exclusive during rollout.

Fix:
Keep the released Desktop tables and primary keys unchanged, route CLI telemetry to additive cli_* tables, and let the new Worker initialize those tables with idempotent CREATE IF NOT EXISTS statements. Keep the migration additive and identical to the runtime bootstrap so migration, deployment, and rollback order no longer affect Desktop ingestion.

Verification:
- pnpm test (workers/crash-report, 54 tests)
- pnpm run typecheck (workers/crash-report)
- git diff --check

* fix(test): make crash report mode check portable

Problem:
The Windows CI job failed because os.FileMode reports synthesized POSIX bits on Windows rather than the 0600 mode requested by AtomicWriteFile.

Root cause:
The test treated Unix permission bits as a cross-platform ACL signal even though Windows enforces file access through inherited ACLs.

Fix:
Keep the 0600 assertion on Unix-like systems and skip only that non-portable mode-bit comparison on Windows while retaining all report creation, sanitization, and bounded-retention checks.

Verification:
- go test ./internal/crashreport
- GOOS=windows GOARCH=amd64 go test -c ./internal/crashreport

* fix(cli): preserve actionable crash report groups

Problem:
CLI panic reports used runtime/debug.Stack as their top frame, so unrelated panics collapsed into one fingerprint. Queue pruning also deleted reports written in an unknown future format.

Root cause:
Top-frame extraction selected the first source location without filtering capture/runtime frames, and pruning treated every JSON file as a current report.

Fix:
Select the first application frame with its sanitized function name, retain a safe fallback, and prune only recognized current-schema report files. Reject newer schemas from old-client listing so their fields remain untouched.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/crashreport ./cmd/reasonix
- go vet ./...
- pnpm test (workers/crash-report, 54 tests)
- pnpm run typecheck (workers/crash-report)
- Windows cross-compile of internal/crashreport

* fix(cli): request consent before telemetry upload

Problem:
A fresh release-build interactive CLI treated a missing telemetry setting as auto and could start anonymous uploads without first informing the user.

Root cause:
The runtime default and persisted decision state were conflated, and telemetry startup had no consent owner before the reporter launched its background flush.

Fix:
Preserve a missing cli_metrics field as an undecided state, show a localized one-time [Y/n] disclosure before the first eligible upload, persist auto or off before continuing, clean pending counters on rejection, and fail closed when saving fails. Keep CI, Safe Mode, development, environment opt-outs, and non-interactive sessions silent and disabled until a decision exists.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/telemetry ./internal/cli
- go vet ./...
- pnpm test (workers/crash-report, 54 tests)
- pnpm run typecheck (workers/crash-report)
- ./scripts/cache-guard.sh
- git diff --check
2026-07-27 23:00:53 +08:00
PaulDing98andSivanCola dc77f2a8c8 fix(jobs): reject artifact path traversal / 拒绝 artifact 路径穿越 (#6936)
* fix(jobs): reject path traversal in artifact parentSession and kind

Issue: #6932

StartForSession trimmed parentSession but did not reject path separators
or '..' components. Combined with filepath.Join(tempRoot, parentSession, id)
and the subsequent os.MkdirAll + os.OpenFile, a caller controlling the
session id or kind could write artifact files outside the manager's temp
root, subject to the running process's filesystem permissions.

This change:

  * Validates parentSession and kind at StartForSession entry. A validation
    failure short-circuits before any goroutine or artifact is created; the
    job is registered as Failed with the validation error in artifactErr so
    list / Wait calls observe it.

  * Adds a defense-in-depth containment check in openArtifactLocked: the
    resolved dir must remain inside m.tempRoot. This catches any future
    path that reaches openArtifactLocked through a different route (e.g.
    SetActiveSessionPath with a malicious sessionPath).

  * Adds unit tests in jobs_security_test.go covering:
      - validatePathSegment table (valid + every rejected character class)
      - StartForSession with traversal parentSession (no file written)
      - StartForSession with traversal kind (no file written)
      - StartForSession with normal input (regression guard)
      - openArtifactLocked with an out-of-root dir in artifactDirs

* docs(jobs): clarify artifact containment boundary

Problem: The validator and regression-test comments implied every accepted artifact path remains under the manager temp root.

Root cause: The explanation overlooked trusted persistent sidecar directories bound by SetActiveSessionPath, which intentionally live next to transcripts.

Fix: Clarify that path-segment validation confines only the temp-root fallback while persistent directories come from trusted store paths.

Verification: go test -count=1 ./internal/jobs/...; go test -count=1 ./...

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-27 22:22:53 +08:00
SivanCola e57297959b Harden dynamic Bash authorization without disrupting Auto / 加固动态 Bash 授权并保持 Auto 低干预 (#6985)
* fix(permission): harden dynamic Bash authorization

Problem:
Broad Bash allow rules could silently authorize nested command and process substitutions, while treating every dynamic shell form as human-required would make Auto unnecessarily disruptive.

Root cause:
Bash rule matching did not distinguish reusable static commands, exact-only dynamic syntax, and nested or indirect execution before applying scoped allows and controller bypasses.

Fix:
Classify Bash ASTs into reusable, exact-only, and human-required tiers. Restrict all dynamic approvals to exact Bash=<literal> grants, require a human for nested or indirect execution outside YOLO, preserve Auto fallback for expansions, assignments, redirects, heredocs, and globs, and apply the same contract across interactive, headless, persistence, CLI, and ACP paths.

Verification:
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- gofmt -l .
- git diff --check

Fixes #6963

* fix(control): remove unused approval wrapper

Problem:
The CI lint job rejected PR #6985 because requestApprovalDecision was left unused after approval paths moved to the option-aware helper.

Root cause:
The dynamic Bash approval refactor replaced the wrapper call sites but did not remove the obsolete wrapper itself.

Fix:
Remove the unused wrapper without changing approval behavior.

Verification:
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- gofmt -l .
- git diff --check

* fix(permission): preserve dynamic compound rule precedence

Problem:
Dynamic compound Bash commands could reach fallback before per-segment Deny or Ask rules were evaluated. Raw rule matching also lost the established :* prefix semantics, and brace expansion or extglob arguments could be treated as reusable static commands.

Root cause:
The approval class short-circuited compound decomposition, the raw matcher interpreted Bash prefix markers as ordinary glob text, and the AST feature pass did not normalize brace expansion before testing whether words were static.

Fix:
Run compound segments through the same complete policy recursively, preserve Bash prefix semantics with AST-aware raw matching, prioritize wildcard tokens in glob matching, and classify brace/extglob or other non-static arguments as exact-only expansions using mvdan syntax nodes.

Verification:
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- gofmt -l .
- git diff --check

* fix(permission): detect indirect builtin execution

Problem:
Bash builtin eval and builtin source could inherit broad Bash authorization even though their direct forms require human approval.

Root cause:
The indirect-execution classifier recursively handled command, exec, env, and other wrappers, but omitted Bash's builtin wrapper.

Fix:
Treat builtin as a recursive command wrapper and add positive and negative regression cases so eval/source require a human while ordinary builtins stay reusable.

Verification:
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- gofmt -l .
- git diff --check
2026-07-27 22:09:03 +08:00
e406e719f6 Preserve MCP config ownership on save / 保存时保持 MCP 配置归属 (#6913)
* fix(config): prevent .mcp.json plugins from leaking into reasonix.toml / 防止 .mcp.json 插件泄漏到 reasonix.toml

RenderTOMLProjectDelta (used by saveProjectIncremental) now filters out
plugins sourced from .mcp.json (MCPSourceProjectMCPJSON). Previously,
saving the config after Load() merged .mcp.json servers into c.Plugins,
the TOML renderer would write them into reasonix.toml. On next load,
their Source provenance changed from project_mcp_json to project_config,
invalidating launch grants and activation receipts keyed by ConfigSource.

Fixes #6702.

* fix(config): preserve MCP plugin ownership on save

Problem:
Scoped TOML saves could copy merged MCP entries into the wrong config file and could leave a removed project plugin behind while foreign-source entries remained in memory.

Root cause:
The project delta filtered only .mcp.json entries, while full rendering and plugin-section removal still used the unfiltered runtime plugin list.

Fix:
Centralize destination-aware plugin filtering for full and incremental renders, retain unknown provenance for directly constructed configs, and mark explicit upgrade imports as user-owned before saving.

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

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

---------

Co-authored-by: jiangrenhang <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-27 22:06:54 +08:00
mchenziyiandSivanCola 76f5414e6b Preserve machine session directory scope and add project root / 保留机器接口 Session 目录并支持项目根目录 (#6970)
* fix(machine): resolve project session scope for --dir

* test(machine): pin project session directory mapping

* fix(machine): preserve explicit session directory scope

Problem:
The PR reinterpreted the documented --dir session-store argument as a project root, which made existing machine Session, Task, and Recovery callers return empty or not-found results.

Root cause:
A single flag was used for two distinct path scopes instead of preserving the existing contract and adding an explicit project selector.

Fix:
Keep --dir as the session storage directory, add --project-root for project-scoped lookup, share directory resolution across Session and Task commands, reject conflicting selectors, and document both modes in English and Chinese.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli with focused machine tests
- go vet ./internal/cli
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- ./scripts/cache-guard.sh
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-27 22:02:21 +08:00
SivanCola 84c6484edb Fix Windows plugin Bash hook runtime detection / 修复 Windows 插件 Bash Hook 运行时探测 (#6980)
* fix: honor configured Bash for Windows plugin hooks

Problem:
Windows plugin hooks that explicitly request Bash can fail after upgrade even when a usable non-standard Git Bash path is configured. Expanded plugin paths can also remain in a form that Git Bash cannot consume.

Root cause:
Hook execution and diagnostics rediscovered Bash independently instead of receiving the effective configured shell path, while Windows plugin-root expansion did not normalize shell-form commands for Bash.

Fix:
Propagate the configured Bash runtime through hook execution and diagnostics, normalize expanded Windows plugin paths for explicit Bash hooks, and report missing runtime dependencies in CLI and Desktop doctor surfaces. Add Superpowers and custom-path regression coverage plus bilingual documentation updates.

Verification:
go test ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
go test -race ./internal/hook ./internal/capdiag
env -u DEEPSEEK_API_KEY go test ./...
(cd desktop && go test ./...)
go vet ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
(cd desktop && go vet ./...)
Windows amd64 cross-compilation for affected root and Desktop packages
git diff --check

Cache-impact: none

* fix: remove obsolete Windows hook wrappers

Problem:
The CI unused-code linter rejects two compatibility wrappers that became unreachable after Hook execution started injecting the effective Bash runtime.

Root cause:
The runtime-aware call sites moved to resolver-injected helpers, but the old zero-argument resolver wrappers remained in the Windows compatibility module.

Fix:
Remove the two obsolete wrappers and keep the shared runtime-aware parsing helpers and cached Bash discovery path.

Verification:
go test ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
go vet ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
gofmt -l internal/hook/windows_compat.go
git diff --check
bash scripts/check-cache-impact.sh with the PR metadata

Cache-impact: none

* test: normalize Superpowers Bash hook expectation

Problem:
The Windows CI test expected a host-native backslash plugin root even though explicit Bash hooks intentionally receive a forward-slash path.

Root cause:
The Superpowers 6.2.0 regression assertion joined the temporary plugin root without applying the same platform-independent slash contract that the Hook loader guarantees for Bash.

Fix:
Normalize the expected plugin root with filepath.ToSlash so the test verifies the intended Git Bash command on Windows and remains stable on Unix hosts.

Verification:
go test ./internal/hook
Windows amd64 test-binary compilation for internal/hook
gofmt -l internal/hook/hook_test.go
git diff --check

Cache-impact: none
2026-07-27 20:29:08 +08:00
SivanColaandgithub-actions[bot] 9968f1e1e2 Update v1.18.0 bilingual release notes / 更新 v1.18.0 中英发布说明 (#6981)
* docs(release): prepare v1.18.0 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): refine v1.18.0 notes

Summary:
Clarify Kimi K3 support across the official Kimi APIs and OpenCode Go, retain the distinct reasoning scales, and record every affected product surface. Keep the Desktop and native CLI channel copy aligned with the merged release contract.

Verification:
Validate the catalog, run the release-notes test suite, render both languages, and check the diff for whitespace errors.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-27 19:40:30 +08:00
SivanCola acc4c54009 Add Kimi K3 support for OpenCode Go and official APIs / 为 OpenCode Go 与官方 API 适配 Kimi K3 (#6921)
* Add native Kimi K3 support for OpenCode Go

Problem:
Reasonix did not expose Kimi K3 in the OpenCode Go subscription preset even though the upstream catalog now serves it.

Root cause:
The curated OpenCode Go model catalog and capability metadata predated Kimi K3, while the generic OpenAI-compatible provider clamped max reasoning effort to high.

Fix:
Add Kimi K3 with vision, 1M context, and max-only reasoning metadata; preserve explicitly supported max effort; safely migrate untouched legacy presets; and synchronize desktop previews and documentation.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend exec tsx src/__tests__/provider-model-refresh.test.ts
- ./scripts/cache-guard.sh

* Fix Kimi K3 compatibility paths

Problem:
Kimi K3 max-only effort metadata was omitted by reasonix-guard assist, and legacy normalization replaced existing custom Kimi K3 overrides.

Root cause:
The recovery command duplicated provider configuration assembly, while the migration assigned the Kimi K3 override unconditionally.

Fix:
Forward resolved effort and capability metadata through the recovery path, merge only missing Kimi K3 defaults, preserve case-insensitive custom override keys, and add regression coverage.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- ./scripts/cache-guard.sh
- git diff --check

* Support Kimi K3 high and max effort

Problem: Kimi K3 was advertised as max-only even though the service accepts both high and max, so /effort high was rejected and capability displays were incomplete.

Root cause: The preset, legacy catalog migration, desktop mock, documentation, and regression fixtures all encoded the old max-only capability.

Fix: Advertise high and max while keeping max as the default, and update every compatibility path and focused regression test. Existing custom Kimi K3 overrides remain unchanged.

Verification: env -u DEEPSEEK_API_KEY go test ./...; (cd desktop && env -u DEEPSEEK_API_KEY go test ./...); pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend exec tsx src/__tests__/provider-model-refresh.test.ts; ./scripts/cache-guard.sh; git diff --check.

* Adapt official Kimi APIs for K3

Problem: The PR exposed Kimi K3 only through OpenCode Go, leaving the official Kimi CN and Global presets without the model or its documented request contract.

Root cause: The direct-API catalogs predated K3, while the generic OpenAI-compatible wire shape drops assistant reasoning, forwards configurable temperature, and uses max_tokens.

Fix: Add K3 capability overrides and conservative legacy catalog migration for both official endpoints; preserve complete assistant messages; omit fixed sampling fields; map output limits to max_completion_tokens; and keep relay behavior isolated.

Verification: env -u DEEPSEEK_API_KEY go test ./...; cd desktop && env -u DEEPSEEK_API_KEY go test ./...; pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend exec tsx src/__tests__/provider-model-refresh.test.ts; ./scripts/cache-guard.sh; git diff --check.

* Preserve K3 reasoning through remote broker

Problem:
Remote Workbench lost the Kimi K3 reasoning round-trip policy when the Desktop provider was represented by the Host broker proxy. A configured PostLLM hook could therefore replace the reasoning persisted for the next official K3 request.

Root cause:
The new ReasoningRoundTripPolicy was implemented by the OpenAI-compatible client and consumed by the Agent, but the broker descriptor and hostProvider only forwarded the older tool-call reasoning capabilities.

Fix:
Carry an optional reasoningRoundTrip capability through provider descriptors and the Remote wire schema, expose it from hostProvider, and add an end-to-end broker regression that proves display-transformed reasoning still replays the original provider text.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/remote/broker
- go vet ./...
- go test ./... (desktop module)
- pnpm typecheck (desktop/frontend)
- go run ./cmd/remote-protocol-gen --check
- ./scripts/cache-guard.sh

* Preserve custom vision settings during K3 migration

Problem: Kimi K3 migration appended vision support to every matching provider, including configurations that explicitly disabled or customized vision models.

Root cause: provider-level vision_models distinguishes an omitted nil list from an explicit empty or user-curated list, but the migration ignored that distinction.

Fix: add Kimi K3 vision support only for omitted lists or exact legacy preset lists, and preserve explicit empty and custom lists for direct Kimi and OpenCode Go migrations. Add regression coverage for both paths.

Verification: go test ./...; ./scripts/cache-guard.sh; go run ./cmd/remote-protocol-gen --check; git diff --check
2026-07-27 19:04:11 +08:00
SivanCola f62790387a docs(release): update v1.18.0 notes
Refresh the bilingual v1.18.0 catalog against the latest main-v2 release boundary, including #6155 channel updates and #6944 signing-gate hardening.

All required CI, CodeQL, release-notes, cache, and platform checks passed.
2026-07-27 11:14:44 +08:00
SivanCola c846ad5559 Harden SignPath stable release gating (#6944)
Problem: Stable release v1.17.21 reached SignPath through release-stable.yml while the provider policy allowed only release-desktop.yml, blocking both Windows architectures after CLI and npm publication.

Root cause: The provider-side build-definition allowlist was maintained manually, and the stable orchestrator did not prove the complete signing path before starting public publishers.

Fix: Add a machine-readable signing contract, workflow call-graph validation, protected fingerprints, exact CODEOWNERS coverage, a zero-publication dual-architecture preflight, and fail-closed standalone attestation checks.

Verification: Full PR CI, CodeQL, Windows Desktop packaging, race, coverage, release workflow contract tests, cache guard, root and Desktop Go tests, vet, actionlint, npm dry build, and diff checks passed.
2026-07-26 09:51:34 +08:00
SivanCola 908ce880e5 Add Stable and Preview release channels / 新增 Stable 与 Preview 双发布渠道 (#6155)
Context:
Reasonix needed one public Stable/Preview contract across native CLI archives and Desktop updates while keeping npm prerelease tags independent.

Changes:
- Add protected native CLI Preview prereleases without moving Homebrew or GitHub Latest.
- Add persistent Desktop Stable/Preview selection, channel-aware update state, R2 pointers, and legacy Canary compatibility.
- Preserve the complete production Windows payload and installer signing chain and fail closed when signing or Preview R2 prerequisites are unavailable.
- Add release gateway routing, workflow contracts, compatibility coverage, and bilingual settings copy.

Compatibility:
Missing and unknown Desktop channel settings remain Stable. Legacy canary, beta, and next settings normalize to Preview. New Preview clients reject stale test-signed Canary manifests while older clients retain the mirrored canary pointer.

Verification:
Root and Desktop Go suites, frontend test:all and production build, crash-report Worker typecheck and 45 tests, release workflow contracts, cache guard, release notes tests, six native CLI target builds, CodeQL, race, and Windows NSIS/portable CI all pass on head 97623c982.
2026-07-25 14:05:00 +08:00
SivanColaandgithub-actions[bot] 75c18e41ec Prepare v1.17.21 release notes / 准备 v1.17.21 中英更新日志 (#6929)
* docs(release): prepare v1.17.21 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): complete v1.17.21 notes

Summary:
Expand the release surfaces beyond Desktop and add the missing CLI diagnostics and recovery ownership changes from PR #6912.

Verification:
Validated the release catalog, rendered the bilingual notes, ran the catalog tests, and checked the diff for whitespace errors.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-25 11:43:41 +08:00
JesonChouandSivanCola dcca564ec8 fix(desktop): fix code font not applied in chat blocks and file preview / 修复代码字体设置在对话区和文件预览中不生效 (#6925)
* fix(desktop): sync typography code-region when global mono font changes (#6907)

When the user switches the global monospace font in Appearance settings,
reset any previously-customised TypographySettings code-region override
(followGlobal=true) so the global choice takes effect in code blocks
instead of being silently blocked by the --typography-code-font inline
style set by applyTypographyPreferences.

Also replace three hardcoded font-family: monospace occurrences
(.remote-file-view__path, .remote-file-view__editor,
.remote-hostkey-dialog__fp) with var(--font-mono, monospace), and
align .code-lines-wrap with the other code-block rules by using
var(--typography-code-font, var(--font-mono)).

* fix(desktop): preserve regional code font overrides

Problem: Selecting a global monospace font silently forced an explicit Code & Tools typography override back to follow-global, while valid quoted preset font stacks were rewritten unnecessarily.

Root cause: The settings handler treated the regional override as stale, and the original patch assumed quoted multi-word family names were invalid after CSS custom-property substitution.

Fix: Keep regional typography independent when the global monospace choice changes and restore quoted multi-word font family names.

Verification: pnpm --dir desktop/frontend exec tsx src/__tests__/typography-preferences.test.ts; pnpm --dir desktop/frontend typecheck; git diff --check.

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

* test(desktop): protect regional code font behavior

Problem: The code-font fix relied on CSS inheritance and settings ownership contracts that were not covered by focused regression tests.

Risk: A future global monospace handler could silently overwrite an explicit regional choice, or nested code elements could fall back to the browser monospace default again.

Coverage: Render Appearance with a customized code region and verify a global monospace change preserves its stored preference and CSS variable. Assert the nested code and line-number viewer font declarations directly.

Verification: pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx; pnpm --dir desktop/frontend exec tsx src/__tests__/typography-overflow-contract.test.ts; git diff --check.

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-25 10:56:21 +08:00
SivanCola cb8dcdc1f7 feat(desktop): add bounded regex search to code previews / 为代码预览添加有界正则搜索 (#6923)
* feat(desktop): add bounded regex search to code previews

Problem:
Workspace code previews only exposed literal search, the find shortcut was scoped to the focused viewer, and the floating search popup could obscure code.

Root cause:
JavaScript regular expressions run synchronously without cancellation, the workspace panel had no search action of its own, and the popup used absolute positioning inside the code viewport.

Fix:
Run opt-in regex searches in an inline worker with hard termination, debounce, stale-request cancellation, and pattern, source, and result limits. Add a visible workspace search action and panel-level shortcut, dock the search toolbar above code, preserve literal search by default, and localize bounded failure states.

Verification:
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend exec tsx src/__tests__/line-number-code.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-changes-errors.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-preview-css.test.ts
- git diff --check
- Browser verification at the narrow dock width

* Fix regex result handling and search toolbar copy

Problem: Valid regex patterns such as .* lost non-empty matches when they also emitted a zero-length result, and the floating copy button remained hidden under the open toolbar.

Root cause: Match collection rejected on the first zero-length result, while the copy affordance was rendered independently of search state.

Fix: Skip zero-length matches with Unicode-safe advancement, report unsupported only for zero-only patterns, move copy into the toolbar while search is open, and let narrow toolbar actions wrap as a group.

Verification: pnpm test; pnpm build; git diff --check; in-app browser verification at a 242 px code-pane width.

* fix(desktop): preserve pending code search requests

* fix(desktop): bound and clarify regex preview search

Problem: Regex worker startup could remain pending without entering the 200 ms timeout, and line-by-line evaluation silently changed regular-expression semantics.

Root cause: The deadline was created only after the async worker import resolved, while regex matching reset state for every source line.

Fix: Start the deadline before worker creation, scan regex requests against the full source with line anchors preserved, and surface explicit localized errors for newline-spanning matches. Add regression coverage for both paths.

Verification: node --import tsx src/__tests__/line-number-code.test.tsx; node --import tsx src/__tests__/workspace-changes-errors.test.tsx; node --import tsx src/__tests__/workspace-preview-css.test.ts; pnpm typecheck; pnpm test:typecheck; pnpm build; git diff --check.
2026-07-25 02:29:46 +00:00
JesonChouandSivanCola acdef9998a fix(desktop): paste text undoable via Ctrl+Z on Windows (#6531) / 修复ctrl+z无法撤回ctrl+v的内容 (#6571)
* fix(tui): add paste undo on Windows via Ctrl+Z (#6531)

On Windows Ctrl+Z has no job-control semantics, so repurpose it as
undo for paste operations. A LIFO undo stack (capped at 50 entries)
records the input state before each paste; Ctrl+Z pops and restores.
Folded pastes and image refs participate without double-pushing.

* fix(desktop): implement paste undo via explicit undo stack (#6531)

Replace the unreliable browser-native undo approach (execCommand /
setRangeText in WebView2) with an explicit paste undo stack in the
Composer. Before each paste the current input text is pushed onto a
ref-managed stack; a Ctrl+Z keydown handler pops and restores the
last saved state. Falls through to browser-native undo when the
stack is empty, preserving normal text-editing undo.

* Expose composer undo and redo controls

Problem:
Composer undo and redo behavior was not discoverable in Settings, help, context menus, or the user guides. YOLO tooltips also kept showing its default binding after a rebind.

Root cause:
The composer handled editing chords and Reasonix transactions locally instead of registering the actions in the shared shortcut catalog. The custom context menu exposed clipboard actions only, and documentation still described Ctrl/Cmd+Y as YOLO-only.

Fix:
Register locked platform undo and redo actions, protect their editing chords from legacy global bindings, reuse the transaction history from the context menu, render live YOLO shortcut labels, and update English and Chinese guidance.

Verification:
pnpm test:all
pnpm build
go test ./... in desktop
Real browser checks for Settings rows, live YOLO rebind labels, context-menu undo/redo, and a clean console

* Fix rich composer edit context menu

Problem:
The rich composer did not open Reasonix's edit context menu, so drafts containing skill or subagent tokens could not use the transaction-aware Undo and Redo actions.

Root cause:
Only the plain textarea wired the shared context-menu handler; RichComposerInput did not expose or forward a contextmenu event.

Fix:
Forward context-menu events from RichComposerInput and reuse the existing Composer edit menu for both editable surfaces. Add regression coverage for undoing and redoing a paste after an inline invocation.

Verification:
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm exec tsx src/__tests__/composer-session-draft.test.tsx
- pnpm exec tsx src/__tests__/composer-context-menu-clipboard.test.tsx
- pnpm test:typecheck
- pnpm build
- git diff --check

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

* Protect composer history from legacy YOLO bindings

Problem:
Legacy custom YOLO bindings on Ctrl/Cmd+Z or Ctrl/Cmd+Shift+Z intercepted
Composer undo and redo, toggling approval mode instead of editing the draft.

Root cause:
Composer matched the local YOLO shortcut before its transaction-aware history
handler and did not apply the reserved editing-chord policy used globally.

Fix:
Exclude reserved Composer undo and redo chords from local YOLO handling. Add
upgrade regression coverage for legacy bindings, rebound YOLO, and the default
Ctrl+Y behavior.

Verification:
- pnpm exec tsx src/__tests__/composer-session-draft.test.tsx (130 passed)
- pnpm exec tsx src/__tests__/keyboard-shortcuts.test.ts (71 passed)
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx (185 passed)
- pnpm exec tsx src/__tests__/shortcut-recorder.test.tsx (25 passed)
- pnpm test:typecheck
- pnpm exec vite build --logLevel error
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-25 02:25:28 +00:00
SivanCola 6e81c1c79f Fix sensitive data exposure in error logs / 修复错误日志敏感信息泄露 (#6924)
* fix: redact sensitive data from error logs

Problem: CodeQL reported two error logging paths that could expose credentials embedded in runtime errors.

Root cause: The heartbeat and bot controller failure paths logged raw error values, while the stronger authentication scrub was private to the control package.

Fix: Add shared credential and error redaction helpers, mask URL userinfo, reuse the helper for authentication errors, and sanitize both reported logging sinks.

Verification: go test ./...; cd desktop && go test ./...; git diff --check.

* fix: close redaction edge cases

Problem: URL userinfo containing raw @ characters could leave a password suffix visible, and repeated credential redaction changed already-redacted KEY=value output.

Root cause: The userinfo regexp stopped at the first @, while redactKeyValues remasked the stronger helper's **** marker as [redacted].

Fix: Consume the complete userinfo segment through the final @ and preserve existing redaction markers during key/value masking. Add regressions for raw @ proxy credentials and idempotent KEY=value errors.

Verification: go test ./...; git diff --check.
2026-07-25 10:06:21 +08:00
SivanCola e3c86bacf5 Fix Dependabot security alerts (#6922)
Problem:
The repository had twelve open Dependabot alerts across the site, desktop frontend, and Cloudflare Worker development toolchains.

Root cause:
Direct dependency ranges and generated lockfiles still resolved vulnerable releases of Astro, PostCSS, sharp/libvips, SVGO, DOMPurify, and js-yaml.

Fix:
Upgrade the site to Astro 7.1.3, refresh the affected frontend transitive dependencies, and move all Worker projects to Wrangler 4.114.0 with compatible workers-types 5.x releases.

Verification:
- Site tests and Astro static production build
- Desktop TypeScript check, Mermaid sanitizer tests, and Vite production build
- Worker TypeScript checks and Wrangler deployment dry-runs
- Crash-report Worker test suite
2026-07-25 08:07:59 +08:00
SivanColaandSivanCola aa8c97f75e feat: Add deterministic planner routing and depth / 增加确定性规划路由与深度 (#6918)
* feat: add deterministic planner routing and depth

Problem:
The two-model coordinator invoked the planner for nearly every work-shaped turn, adding latency to questions, contextual follow-ups, and clearly atomic edits while producing plans with inconsistent engineering detail.

Root cause:
The planner gate returned only a boolean and inferred intent from the composed prompt, so it could not represent planning depth, host-owned approval, trusted turn state, or observable routing reasons.

Fix:
Introduce deterministic executor-only, plan-and-execute, and plan-for-approval routes with light/full depth and privacy-safe reason codes. Feed the gate pristine user text plus trusted Goal, Delivery, Plan Mode, synthetic, and conversation metadata. Add stable planner contracts, bounded per-turn research, host-enforced plan approval, executor validation guidance, documentation, and regression matrices.

Verification:
- ./scripts/cache-guard.sh
- go test ./internal/control ./internal/agent ./internal/boot -count=1
- go vet ./internal/control ./internal/agent ./internal/boot
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- focused race tests for planner routing, metadata, approval, and research budgets

Cache impact:
Low. The planner system prompt changes once to define stable light/full contracts; per-turn route and depth remain in the user turn. Tool schemas and ordering are unchanged, and the cache guard remains above its 90 percent threshold.

* fix: preserve planner handoff semantics

Problem:
Bare plan-first requests were classified as approval-only turns, so they could stop after planning instead of reaching the executor.

Root cause:
The deterministic directive table conflated plan-and-execute, plan-for-approval, and explicit no-execution intent.

Fix:
Add a distinct plan-only route, reserve plan-for-approval for explicit confirmation language, route bare plan-first requests through the executor, and preserve both execution boundaries on planner failure. Update English and Chinese contracts plus regression coverage.

Verification:
- go test ./internal/agent ./internal/control
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- ./scripts/cache-guard.sh

* fix(planner): preserve execution after bounded planning

Problem: Task-first plan-only and approval boundaries could be missed, while a planner research-budget pause stranded ordinary work without a usable executor continuation.

Root cause: Routing only recognized leading planning directives, and Coordinator preserved tool-loop pauses even though planner research limits are host-owned per-turn policy.

Fix: Parse unquoted execution boundaries across the request with negation handling; roll back incomplete planner turns; fall back to the executor with the original task for ordinary plan-and-execute while keeping plan-only and approval routes fail-closed. Update docs and regression coverage.

Verification: go test ./... -count=1; go test -race ./internal/control ./internal/agent -count=1; go vet ./...; ./scripts/cache-guard.sh; live Planner-to-Executor tests with DeepSeek, LongCat, and GLM Coding Plan.

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

* test(planner): verify plan-only continuation

Problem: Plan-only coverage proved that execution was suppressed and the plan was persisted, but did not prove that a later user command actually reached the executor with that saved plan.

Fix: Add a two-turn coordinator regression that plans without execution, then sends an execution continuation and verifies the executor receives the persisted plan while the planner is not invoked again.

Verification: go test ./internal/agent -count=1

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

* fix(planner): skip bounded read-only actions

Problem: Short read-only commands such as running tests, reviewing a PR, or inspecting one file still invoked the planner and could be upgraded to a full plan in Delivery mode.

Root cause: Read-only verbs shared the generic work-term bucket with mutations, and anchored work was evaluated before ambiguous scope.

Fix: Detect requests containing only read-only work terms and route bounded non-ambiguous cases directly to the executor. Preserve full planning for high-risk, cross-surface, structured, complex, or ambiguous read-only work, and prioritize ambiguous scope over an anchored target. Update English/Chinese docs and the routing matrix.

Verification: go test ./... -count=1; go test -race ./internal/control ./internal/agent -count=1; go vet ./...; ./scripts/cache-guard.sh.

Co-authored-by: SivanCola <[email protected]>
2026-07-25 07:56:30 +08:00
SivanCola 87999b9747 Sign every Windows payload executable / 签署全部 Windows 载荷程序 (#6904)
* fix(release): sign Windows payload executables

Problem:
Windows Defender can quarantine reasonix-desktop.exe after install or update even though the downloaded NSIS installer is signed.

Root cause:
The release workflow applied Authenticode only to the finished NSIS container. The desktop executable, support binaries, portable archive entries, and generated uninstaller remained unsigned after extraction.

Fix:
Sign the exact Windows payload through a versioned SignPath configuration, rebuild the portable archive and NSIS installer from those signed files, verify the payload before signing the outer installer, and fail official releases closed when signing is unavailable. Preserve and inject a signed NSIS uninstaller in the second packaging pass.

Verification:
- go test ./... (desktop module)
- env -u DEEPSEEK_API_KEY go test ./...
- bash scripts/release-workflows.test.sh
- ./scripts/cache-guard.sh
- actionlint release workflows
- bash syntax and git diff checks

* fix(release): separate canary installer signing config

* fix(release): automate gated SignPath approvals

Problem:
Release certificates require one SignPath approval per request, causing four extra manual approval actions after the GitHub release gate.

Root cause:
The desktop workflow submitted each request synchronously and waited for an interactive SignPath approver even though the same release authority had already approved the protected GitHub environment.

Fix:
Submit requests asynchronously, validate the expected signing policy, approve them through the dedicated CI identity, poll to a final successful state, and securely download the signed artifacts. Exercise the same path in canary with an approval-enabled test policy and document the administrator contract.

Verification:
- cd desktop && go test ./...
- bash scripts/release-workflows.test.sh
- actionlint release-stable.yml release-desktop.yml
- git diff --check

* fix(release): default SignPath API endpoint

The GitHub connector does not expose a SignPath API URL when requests are submitted asynchronously. Use SignPath's documented SaaS endpoint by default and keep the script override available for self-hosted installations.

Verified with desktop Go tests, release workflow contract tests, actionlint, and git diff --check.

* fix(release): restrict production signing origin

Problem:
Repository-level SignPath credentials were available to workflows on ordinary repository branches, while the production signing policy allowed every branch origin. A writer could therefore construct an unreviewed workflow that requested a trusted production signature.

Root cause:
Stable and prerelease builds ran directly from tag origins, so the SignPath policy could not use one exact protected branch without breaking tag releases. The policy had consequently been configured with an unrestricted branch wildcard.

Fix:
Relay stable and desktop prerelease tags to the current workflow on protected main-v2, keep immutable tags as build candidates, add a gated non-publishing production-signing smoke mode, and codify the exact main-v2 SignPath origin in tests and administrator documentation.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint v1.7.12 on all changed workflows
- cd desktop && go test ./...
- cd desktop && go test . -run signing contract tests
- ./scripts/cache-guard.sh
- git diff --check

* fix(release): wait for manual smoke approval

Problem:
The first production-certificate smoke run failed immediately after submitting its two payload requests when the CI identity had intentionally been removed from the production approver list.

Root cause:
The request completion helper always called the SignPath Approve API as soon as a request entered WaitingForApproval, even in the explicitly human-reviewed smoke mode.

Fix:
Add an external-approval mode that polls without approving, enable it only for production signing smoke runs, and document and test the manual verification boundary. Normal canary and release automation retains CI approval.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint v1.7.12 on release-desktop.yml
- cd desktop && go test . -run signing contract tests
- git diff --check
2026-07-25 06:53:53 +08:00
JesonChouandSivanCola a3e0f10be4 fix(desktop): fix code preview line-number alignment and redesign search bar / 修复代码预览行号对齐并重构搜索栏 (#6910)
* fix(desktop): redesign code preview search as Codex-style floating popup, fix line-number gutter alignment / 重构代码预览搜索为 Codex 风格浮动弹窗,修复行号对齐 (#6906)

- Fix line-number gutter cutoff caused by box-sizing: border-box conflicting
  with minWidth on .code-line-ln — add box-sizing: content-box so the ch-based
  minWidth only constrains content area, not padding+border.

- Replace the full-width inline search bar with a floating popup positioned
  at top-right (absolute, 42px from top), matching Codex visual style:
  rounded corners (8px), box-shadow, compact layout (gap:2px, padding:3px 6px).

- Add search icon (magnifying glass SVG) before the input field.

- Add regex toggle (*) alongside existing Aa (case) and ab (whole-word)
  toggles. Invalid regex patterns degrade gracefully to no results.

- Add workspace.searchNoResults and workspace.searchUseRegex i18n keys
  for en/zh/zh-TW.

- Bump search popup z-index to --z-local-handle (4) so the close button
  renders above the copy button.

- Remove obsolete code-block__wrap--search-open copy-button offset rules.

* fix(desktop): address code review feedback for code preview search / 修复代码审查反馈:搜索输入框宽度、正则-全词互斥、翻译和测试覆盖 (#6906)

- Change search input from fixed 120px to flex layout (min 80px, max 240px)
- Make wholeWord and useRegex toggles mutually exclusive
- Improve zh-TW translation: '正則' -> 'Regex'
- Add test coverage for regex search, invalid regex, and useRegex+wholeWord

* fix(desktop): make search popup width adaptive to prevent overflow past code area / 搜索弹窗宽度自适应,防止超出代码可视区域

- Add max-width: calc(100% - 16px) to .code-search to constrain within container
- Change input to flex: 1 1 60px with min-width: 60px, remove fixed max-width
- Input now shrinks when space is tight instead of overflowing into the file tree

* Keep code preview search literal and responsive

Problem:
The PR exposed arbitrary JavaScript regular expressions on the React render path, allowing expensive patterns to block the WebView and zero-width Unicode matches to return duplicate results.

Root cause:
The regex toggle widened the existing escaped literal-search contract without an isolated, time-bounded execution path.

Fix:
Remove the regex mode and its UI/localization surface while preserving the floating search popup, case matching, whole-word matching, navigation, and no-results feedback. Add a regression test proving regex metacharacters remain literal search text.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/line-number-code.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-preview-css.test.ts
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- Real-browser code preview search, layout, close, and console checks

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-24 23:56:09 +08:00
SivanCola dadce78d0a Fix CLI TUI diagnostics and recovery ownership / 修复 CLI TUI 日志与恢复所有权 (#6912)
* fix(cli): isolate TUI diagnostics and recovery ownership

Problem:
Background structured logs and plugin stderr could overwrite the Bubble Tea composer. Recovery branches could also continue without the CLI session lease, and post-autosave metadata rewrites could be misclassified as same-revision conflicts.

Root cause:
The interactive CLI left the process logger and initial boot stderr attached to the terminal, did not wire OnSessionRecovered to its lease keeper, and classified some in-place session mutations separately from the mutation itself.

Fix:
Route interactive diagnostics through a private bounded log, propagate the diagnostic writer and recovery callback through every controller build, move the lease before recovery commits, and make persisted-history rewrites atomic.

Verification:
- go test -race ./internal/cli ./internal/control ./internal/agent ./internal/guardian
- env -u DEEPSEEK_API_KEY go test ./...
- go build ./cmd/reasonix
- Windows amd64 test-binary cross-compile

* fix(cli): preserve recovery lease across all runtimes

Problem:
The recovery lease callback was only wired into interactive chat. Headless run and serve sessions could move to a recovery branch while still guarding the original path, and serve controller rebuilds could publish a post-snapshot recovery path before its lease followed.

Root cause:
Session recovery ownership lived in a CLI-specific helper, serve attached its keeper only to explicit HTTP binding operations, and model switches rebound the pre-snapshot path after publishing the replacement controller.

Fix:
Move the recovery handoff onto SessionLeaseKeeper, install it for chat, run, serve, and rebuilt serve controllers, and bind the replacement controller's actual post-snapshot path before publishing it. Preserve the outgoing controller on binding failure and keep lease errors path-free.

Verification:
- go test -count=1 ./internal/control ./internal/cli ./internal/serve
- go test -race -count=1 ./internal/control ./internal/cli ./internal/serve
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && go test -count=1 ./...
- go vet ./...
- go build ./cmd/reasonix
2026-07-24 23:29:32 +08:00
SivanCola 72a1e32b49 Fix cross-platform plugin Hook execution contracts / 修复跨平台插件 Hook 执行契约 (#6911)
* Fix plugin hook execution contracts

Problem: Windows plugin hooks with compound commands or nested quoting could still fail after the narrow quoted-batch compatibility fix, while explicit empty args could lose exec-form semantics across JSON round trips.

Root cause: Hook launch behavior was inferred from argument length and platform shell heuristics instead of preserving the manifest's exec-form or shell-form contract. CMD and PowerShell command strings also passed through incompatible quoting layers.

Fix: Preserve args field presence and shell selection through plugin parsing and loading, add explicit legacy/exec/shell execution modes, transport raw CMD and encoded PowerShell scripts without token re-rendering, retain the existing simple batch compatibility path, and document the cross-platform contract.

Verification: Focused Hook and plugin package tests, race and shuffled tests, fuzzing, root and Desktop full suites, go vet, and Windows/Linux cross-compilation.

* Fix Windows hook output regressions

Problem: The new compound CMD regression expected cmd.exe to strip quotes from %1, leaving the Windows job red. PowerShell hook scripts also preserved UTF-16LE input transport without forcing captured output to UTF-8.

Root cause: The test confused %1 with the dequoting %~1 form, and the Hook-specific EncodedCommand path did not reuse the shell runtime's existing PowerShell output-encoding prologue.

Fix: Align the CMD assertion with native batch semantics, share the PowerShell UTF-8 script wrapper across shell and Hook launchers, and exercise Chinese plus emoji output in the Windows runtime test.

Verification: Focused Hook/plugin/sandbox tests and vet; race/shuffle count 3; root and Desktop full suites; full vet; Windows amd64 test cross-compilation; PowerShell encoding fuzzing.
2026-07-24 23:07:09 +08:00
SivanCola 4b2ef3dabb feat(desktop): add native crash diagnostics / 增加桌面原生崩溃诊断 (#6900)
* feat(desktop): add native crash diagnostics

Problem:
Windows users could report that the desktop closed and would not reopen, but native process exits, UI-thread stalls, WebView2 failures, and window-restore hangs left too little durable evidence for analysis.

Root cause:
The desktop only persisted panics from explicitly wrapped goroutines in one overwrite-prone file. Startup tracking stopped distinguishing a healthy process that later vanished, and Windows native/WebView2/window-restore failure paths were not connected to the diagnostic pipeline.

Fix:
Capture Go runtime fatal output, retain a bounded backward-compatible report queue, heartbeat startup lifecycle state, and attribute abnormal exits to coarse uptime, install, and update buckets. Add a Windows Wails-window watchdog, WebView2 process-failure classification, and durable window-restore journaling. Extend aggregate metric ingestion and the health dashboard, while redacting panic values, secrets, and personal path segments and documenting the telemetry behavior.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race . -run focused crash queue tests
- go test -race ./internal/repair -run TestStartupTracker
- Windows amd64 cross-compile of the desktop test binary
- desktop frontend typecheck, focused settings tests, and production build
- crash-report worker: 42 tests and TypeScript typecheck
- git diff --check

* test(desktop): make hang assertions platform-aware

Problem: Windows desktop CI expected macOS crash labels and metric buckets even though the watchdog correctly emits Windows-specific diagnostics.\n\nFix: derive the expected label, error type, and metric bucket from the same platform identity helpers used by the implementation.\n\nVerification: targeted macOS tests pass; Windows amd64 test binary cross-compiles successfully.

* fix(desktop): remove crash watchdog stalls

Problem: the Windows UI watchdog allocated a permanent syscall callback on every one-second heartbeat, and the window-restore journal used a crash-safe fsync/retry path immediately before showing the window.

Root cause: Windows callbacks cannot be released and the Go runtime has a finite callback table; AtomicWriteFile can also wait on antivirus-held rename locks.

Fix: register one serialized EnumWindows callback for the process, keep the restore journal best-effort without fsync/retry, and add long-run Windows plus journal regression coverage.

Verification: go test ./... (desktop); targeted race tests; go vet ./...; Windows amd64 cross-compile; cache guard.

* fix(desktop): make crash diagnostics process-safe

Problem:
Window restore diagnostics recorded success after Wails only queued UI-thread work, and routine duplicate launches shared the primary process's fatal-output file.

Root cause:
The restore timeout watched the WindowShow call instead of native window state, while fatal output used one process-global path before Wails enforced its single-instance lock.

Fix:
Wait asynchronously for the Windows window to become visible and non-minimised, fence superseded restore attempts, and retain the journal until a real acknowledgement. Store fatal output and structured-panic markers per PID, process only dead owners, and keep backward-compatible legacy dump ingestion.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race .
- cd desktop && go vet ./...
- Windows amd64 desktop test cross-compile
- ./scripts/cache-guard.sh
- git diff --check
2026-07-24 23:04:59 +08:00
SivanColaanderphenheimer a6e1459621 Fix Desktop session runtime ownership / 修复桌面会话运行时所有权 (#6897)
* fix(desktop): canonicalize runtime process identity

Problem: executable-based single-instance IDs let stable, canary, and portable builds concurrently write the same Reasonix home, while path aliases could split session lease identity.

Root cause: process ownership was keyed by binary location and fresh paths resolved only their immediate parent.

Fix: hash the canonical REASONIX_HOME and resolve every missing path component through the nearest existing symlink or junction ancestor, preserving isolated homes and dev-mode parallelism.

Verification: Go agent tests, Desktop tests, Windows cross-compilation, and canonical-path race tests pass.

* fix(desktop): unify writable session runtime ownership

Problem: Goal and Delivery mode rebuilds, duplicate tab restore, background reattachment, and saved-session navigation could build a second controller for one session or tear down the live source before a target was proven usable.

Root cause: controller startup, lease ownership, deferred retries, detached-session attachment, and path switching were coordinated per WorkspaceTab without a canonical process-wide admission record or one atomic controller/lease/path commit.

Fix: add a SessionRuntime registry keyed by runtime ID and canonical session key; attach or wait on existing local runtimes; safely reclaim only orphaned current-process leases; and make saved-session switches build, restore, validate, and lease a candidate before atomically publishing it. Target failures preserve the source controller, lease, path, mode profile, and epoch.

Verification: full Desktop and repository tests, Desktop/Agent/Controller/lease race suites, all 18 collaboration-token-mode orderings, deterministic A/B/A navigation fencing, frontend tests/build, Wails bindings, and a production Desktop build pass.

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

* fix(frontend): fence sends and events by runtime state

Problem: the composer could append an optimistic user bubble while a workspace was starting or lease-blocked, and late events from a replaced controller could mutate the new transcript.

Root cause: frontend readiness used only legacy booleans and controller events had no process-runtime generation fence.

Fix: expose structured runtime phase and issue metadata, gate every submit path before optimistic dispatch, seed and advance per-tab runtime epochs, and ignore events from obsolete epochs while retaining legacy event compatibility.

Verification: the full frontend suite, focused send-failure tests, TypeScript checking, and the production Vite build pass.

* docs: record desktop session ownership fix

Problem: the user-visible impact and compatibility boundary of the Desktop self-lock fix were not documented.

Fix: describe single-runtime session ownership, epoch-fenced events, readiness-gated sends, and REASONIX_HOME-scoped single-instance behavior in the Unreleased changelog.

Verification: Markdown diff inspection and repository privacy scan pass.

* test(desktop): align CI fixtures with runtime ownership

Problem: CI found an unused private runtime helper and a Linux timing path where a Heartbeat test injected Ctrl plus Ready without publishing the matching SessionRuntime state.

Fix: remove the unused helper and advance the injected test runtime exactly as the production controller publication path does; production readiness checks remain strict.

Verification: the Heartbeat submission and pending-prompt tests pass for 20 repetitions and for 5 race-enabled repetitions, and go vet passes.

* fix(desktop): wait for starting session runtimes

Problem: two persisted tabs restoring the same session could attach the first tab's starting placeholder before its controller was published, retire both candidate builds, and leave the visible session permanently starting.

Root cause: startup called the general attach path before claimSessionRuntime classified the registered phase, and applyRuntimeTab could create a second registry record when adopting a legacy runtime into a target that already owned a placeholder.

Fix: make claimSessionRuntime the startup admission entry, refuse attachments from non-ready runtime owners, reuse the target placeholder when adopting compatible visible or detached runtimes, and cover the starting-to-ready duplicate-tab handoff.

Verification: focused runtime tests passed for 10 repetitions; the runtime/mode/navigation race matrix passed for 3 repetitions; Desktop go test ./..., go test -race ., go vet ./..., go build ./..., and git diff --check passed.

---------

Co-authored-by: erphenheimer <[email protected]>
2026-07-24 16:13:54 +08:00
SivanCola 2c1df5b4f9 Trust configured MCP and Hooks by default / 默认信任已配置的 MCP 与 Hooks (#6889)
* fix(mcp): trust and surface project servers

Problem:
Project-provided MCP servers were blocked behind a separate launch authorization flow and were not clearly distinguished from global servers in Desktop or CLI management views.

Root cause:
Project scope was coupled to launch approval, while configured-server lookup and presentation did not preserve project provenance consistently.

Fix:
Treat project MCP configuration as user-authorized by default, preserve workspace-scoped activation and relative working directories, load project config for reconnects, and group project, global, and managed servers consistently across Desktop and CLI.

Verification:
- ./scripts/cache-guard.sh
- go test ./...
- cd desktop && go test ./...
- pnpm build
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts

* fix(mcp): preserve config scopes and precedence

Problem:
Global MCP installs and project declarations shared persistence paths, so Desktop and CLI actions could write the wrong config, store activation against the wrong workspace, or remove same-name declarations across scopes.

Root cause:
Runtime config merging preserved the effective entry but persistence call sites did not consistently carry its owning source or the controller workspace.

Fix:
Write explicit installs to the user-global config, keep project edits in reasonix.toml or .mcp.json, apply deterministic project-over-global precedence, and restore the next same-name fallback after removing an override. Align Desktop, CLI, TUI, imports, Planner-facing runtime state, and documentation with the same rules.

Verification:
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- cd desktop/frontend && pnpm run typecheck
- cd desktop/frontend && pnpm build
- ./scripts/cache-guard.sh
- git diff --check

* test(mcp): satisfy staticcheck in handler

Problem:
The PR lint job rejected the new MCP test handler with staticcheck QF1003.

Root cause:
The handler used an if/else-if chain on the same request method value.

Fix:
Use a tagged switch for initialize and tools/list responses without changing test behavior.

Verification:
- go test ./internal/control
- git diff --check

* feat: trust configured project hooks by default

Problem:
Project hooks required a separate workspace trust action even though configured MCP servers and installed plugin hooks already use low-friction trusted defaults. This added a second confirmation model and left Desktop, CLI, diagnostics, and documentation describing a trust state users should not need to manage.

Root cause:
Hook loading and inspection were gated by a persistent trust.json store, with that state propagated through boot, slash commands, Wails settings, diagnostics, and compatibility payloads.

Fix:
Load project hooks automatically whenever a project root is present, while preserving Safe Mode as the emergency disable path. Remove the trust store and user-facing trust controls, and keep legacy CLI commands, Wails methods, LoadOptions.Trusted, and trusted_project JSON as backward-compatible no-ops or enabled values. Update tests, diagnostics, localization, and documentation to describe automatic loading.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- pnpm typecheck
- pnpm check:css
- pnpm exec tsx src/__tests__/diagnostics-settings.test.tsx
- ./scripts/cache-guard.sh
- Desktop browser validation for project Hooks
- git diff --check

* fix: keep MCP auth cleanup in the active workspace

Problem: The TUI clear-auth action followed the process working directory instead of the active controller workspace, and Desktop CI was blocked by an orphaned helper.

Root cause: Authentication cleanup bypassed the workspace selection used by the MCP manager, while the removed project-trust flow left an unused Desktop path helper behind.

Fix: Centralize TUI MCP workspace selection, add a cross-workspace regression test, remove the unused helper, and document the executable repository-config boundary with Safe Mode recovery guidance in English and Chinese.

Verification: go test ./internal/cli; go vet ./...; go test ./...; cd desktop and run go vet ./..., go build ./..., and go test ./...; ./scripts/cache-guard.sh; git diff --check.

* fix(mcp): preserve advertised cold-cache connect calls

Problem:
A provider could call a cold-cache MCP connect placeholder that had been advertised at request serialization time, but background schema discovery could replace it before dispatch and produce an unknown-tool failure.

Root cause:
Tool calls are resolved against the current registry after the provider returns, while cache-miss discovery intentionally swaps the synthetic connect placeholder for the server's real tools.

Fix:
Recognize a stale synthetic connect call as completed when the same MCP namespace now exposes real tools, and let already-resolved lazy placeholders report the same controlled result. Keep genuine unknown calls unchanged and add regressions at both dispatch and lazy-lifecycle boundaries.

Verification:
- go test ./...
- go test -race ./internal/plugin ./internal/agent
- go vet ./internal/plugin ./internal/agent
- ./scripts/cache-guard.sh
- delayed cold-cache LongCat MCP integration test
2026-07-24 16:00:36 +08:00
SivanCola 7663495c91 fix(desktop): keep provider retries cancellable / 保持桌面端重试可停止 (#6902)
* fix(desktop): keep provider retries cancellable

Problem: During provider retry backoff, a stale idle tab snapshot could clear the frontend running state, hiding Stop until all retry attempts finished.

Root cause: The retrying event only updated retry counters even though it is authoritative evidence that the foreground turn remains active and cancellation-aware.

Fix: Restore running, turn-active, cancellable, and timing state when handling retrying events, with a regression that reproduces the stale snapshot ordering.

Verification: Focused controller progress, cancel reconciliation, and composer tests pass; frontend typecheck, CSS checks, and git diff checks pass.

* fix(desktop): fence retry state from stale snapshots

Record retry activity on the same monotonic clock used by runtime snapshot fetches. Ignore idle snapshots started before the latest retry so Stop and Escape cancellation stay available, while allowing a fresh idle snapshot to reconcile a missed turn_done and clear the retry indicator.
2026-07-24 15:53:09 +08:00
SivanCola deb679bfcf Reduce Auto failure interruptions / 减少 Auto 失败恢复打断 (#6896)
* fix(recovery): reduce Auto failure interruptions

Problem:
Auto injected a long guard message after ordinary tool and verification failures, then treated unrelated follow-up work as recovery that could reach the reviewer or a user confirmation. Recovery pause copy could also appear duplicated or in the wrong language.

Root cause:
The recovery gate treated an active failure as a task-wide boundary, did not distinguish transient timeouts, and allowed non-plan reviewer outcomes to escalate to human prompts. Localized clients also rendered the backend English fallback alongside localized copy.

Fix:
Classify failure evidence, isolate retries to the exact failed operation, keep unrelated and host-proven read-only work automatic, and reserve human decisions for structured plan changes. Add bounded timeout guidance, backward-compatible persistence, localized recovery_paused copy, and matching desktop, CLI, serve, and documentation coverage.

Verification:
- go test ./... -count=1 with DeepSeek credentials unset
- go test -race ./internal/recovery ./internal/control -count=1
- go vet ./internal/recovery ./internal/agent ./internal/control
- ./scripts/cache-guard.sh
- go test ./... -count=1 in desktop/
- pnpm typecheck and focused frontend recovery tests

* fix(recovery): enforce runtime retry boundaries

Problem:
Runtime-shaped recovery proposals always carried a preview, so their fingerprint did not match completed observations and the exact-operation failure limit was not enforced. Tool-owned MCP deadlines were also reported as cancellations and skipped transient recovery classification.

Root cause:
One fingerprint served both operation accounting and preview-bound approvals, despite those records having different shapes. The Agent adapter also inferred cancellation from the returned error cause instead of the parent turn context.

Fix:
Separate preview-free operation identity from preview-bound approval identity, and treat a deadline as cancellation only when the parent context ended. Add regressions for real proposal previews, internal MCP deadlines, and parent deadline preservation.

Verification:
- go test ./... -count=1 with DeepSeek credentials unset
- go test -race ./internal/recovery ./internal/agent ./internal/control -count=1
- go vet ./internal/recovery ./internal/agent ./internal/control ./internal/cli ./internal/serve
- ./scripts/cache-guard.sh
- go test ./... -count=1 in desktop/
- focused frontend recovery tests and pnpm typecheck
2026-07-24 14:20:59 +08:00
SivanCola 1ae7e48ba6 Streamline stable release preparation and prepare v1.17.20 notes / 精简稳定版准备流程并准备 v1.17.20 发布说明 (#6893)
* docs(release): prepare v1.17.20 notes

Summary:
Add reviewed bilingual stable release notes for v1.17.20, covering the machine CLI interfaces, trusted MCP delegation, Debian updater bootstrap, recovery changes, and desktop reliability fixes.

Compatibility:
Document the one-time Debian package bootstrap, matching Desktop/Remote host requirement, machine identity initialization, and installed-MCP trust boundary without changing runtime behavior.

Verification:
- node scripts/release-notes.mjs validate
- rendered the v1.17.20 GitHub release notes successfully
- node --test scripts/release-notes.test.mjs
- bash scripts/release-workflows.test.sh
- git diff --check

* ci(release): reuse release-bound pull requests

Problem:
Preparing reviewed bilingual notes opened a dedicated pull request for every stable version even when an existing final product or release-infrastructure pull request already defined the candidate boundary.

Root cause:
The preparation workflow always selected a version-specific release-notes branch and had no validated way to update an existing review branch.

Fix:
Add an optional target_pr path that accepts only an open same-repository pull request into main-v2 containing the latest trunk. Reuse that branch, expose the rendered draft in the workflow summary, retain the dedicated pull request as a fail-closed fallback, and document the release-PR frequency rule.

Verification:
- bash scripts/release-workflows.test.sh
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- rendered the v1.17.20 release notes successfully
- git diff --check
2026-07-24 12:03:50 +08:00
mchenziyiandSivanCola 16d71aa475 feat(cli): add redacted machine interfaces / 增加脱敏机器接口 (#6859)
* feat(cli): add redacted machine interfaces

* fix(cli): repair machine lifecycle projections

Problem:
Persisted running jobs could be resurrected after restart with a closed done channel and no cancel function, while task projections reported running subagents as finished and artifact-complete. Machine session IDs also exposed model-bearing transcript names.

Root cause:
Writing start metadata invalidated the loader's terminal-only assumption, and the machine projection treated update timestamps and file allocation as terminal completion.

Fix:
Normalize restored running jobs to an interrupted terminal state, keep in-flight artifacts incomplete, project stale state from the session lease, verify subagent artifact presence, hash machine session identities, reject conflicting event-output flags, and document the schema contract in English and Chinese.

Verification:
- go test ./internal/jobs ./internal/cli
- go test -race ./internal/jobs
- go test -race ./internal/cli -run machine-interface focused tests
- go vet ./internal/jobs ./internal/cli
- ./scripts/cache-guard.sh
- root test sweep passed all deterministic packages; the invalid-key live probe was isolated and its package passed with DEEPSEEK_API_KEY unset

* fix(jobs): preserve live tasks across runtime rebuilds

Problem:
A replacement jobs manager could rewrite a still-running background task as interrupted while the original controller continued executing it. CLI and serve runtime switches could trigger that overlap because they only guarded foreground turns.

Root cause:
Persisted running artifacts did not identify their live manager, so every loader assumed they belonged to a dead runtime. Runtime rebuild entry points also used inconsistent active-work checks.

Fix:
Add a private optional manager owner ID to job metadata, defer artifacts owned by a live in-process manager, and reload them after terminal completion. Normalize model, effort, skill, work-mode, and serve rebuild guards to reject foreground work, pending prompts, or background jobs.

Verification:
- go test ./...
- go test -race ./internal/jobs
- go test -race ./internal/cli ./internal/serve
- go vet ./internal/jobs ./internal/cli ./internal/serve ./internal/i18n
- ./scripts/cache-guard.sh

* fix(jobs): require session ownership for stale repair

Problem:
A manager loading persisted running job artifacts could rewrite them as interrupted while another Reasonix runtime still owned and wrote the same transcript. Bot and Remote Workbench sessions also did not retain transcript leases across their full lifecycle.

Root cause:
Process-local manager owner IDs cannot prove that a missing owner is dead in another process, and the artifact loader had no session-write ownership check. Bot and Remote create, restore, fork, rotate, and close paths did not consistently acquire, transfer, and release session leases.

Fix:
Inject a fail-closed session ownership probe into jobs managers, repair abandoned running artifacts only while the current process holds the transcript lease, and keep leases attached to Bot and Remote sessions until controller teardown. Add regression coverage for denied repair, lease transfer, concurrent restore, and close-time release.

Verification:
- go test ./internal/jobs ./internal/agent ./internal/control ./internal/bot ./internal/remote/workbench/runtime ./internal/boot
- go test -race ./internal/jobs ./internal/agent ./internal/bot ./internal/remote/workbench/runtime
- go vet ./internal/jobs ./internal/agent ./internal/control ./internal/bot ./internal/remote/workbench/runtime ./internal/boot
- go test ./... (all deterministic packages passed; the invalid local DeepSeek credential probe passed with DEEPSEEK_API_KEY unset)
- cd desktop && go test ./...
- ./scripts/cache-guard.sh

* test(remote): release session leases before cleanup

Problem:
Windows CI could not remove Remote runtime TempDir trees because two tests ended while the new session lease lock files were still open.

Root cause:
The tests created or rotated leased runtime sessions but never closed their Server instances before testing.TempDir cleanup. Unix permits unlinking open files, while Windows correctly rejected the cleanup.

Fix:
Register snapshotAndClose cleanup for the rotation fixture and broker integration test so controllers close and session leases release before temporary directories are removed.

Verification:
- go test ./internal/remote/workbench/runtime -run 'TestSessionRotationRegistryFailureReturnsCommittedEpoch|TestRuntimeControllerUsesDesktopBrokerWithoutHostKey' -count=1
- GOOS=windows GOARCH=amd64 go test -c -o /tmp/pr6859-runtime.test.exe ./internal/remote/workbench/runtime
- git diff --check

* fix(cli): preserve legacy run session IDs

Problem:
Existing json and stream-json automation received a hashed session_id, so the documented resume query could no longer resolve the value emitted by a run.

Root cause:
runAgent applied the new redacted machine-session transform before finalizing every structured output format instead of limiting it to events-jsonl.

Fix:
Select the final session ID by output format: preserve the established raw branch ID for text, json, and stream-json, and hash only events-jsonl. Add focused compatibility coverage for every format.

Verification:
- go test ./internal/cli -run 'TestRunOutput(SessionIDPreservesExistingFormats|JSONResult|EventsJSONLIsStructuredAndRedacted)' -count=1
- go test ./internal/cli ./internal/jobs ./internal/agent ./internal/bot ./internal/remote/workbench/runtime ./internal/serve ./internal/boot
- go test -race ./internal/cli ./internal/jobs
- merged with origin/main-v2: go test ./internal/cli ./internal/jobs
- merged with origin/main-v2: go test -race ./internal/cli
- merged with origin/main-v2: ./scripts/cache-guard.sh
- git diff --check

* fix(machine): fail closed on lifecycle repair errors

Problem:
- Session rotation could leave remote and bot controllers serving an unleased transcript after lease transfer failed.
- Machine event JSONL exposed provider-controlled tool identifiers and names.
- Failed interrupted-job metadata repair could publish an in-memory state that was not durable.

Root cause:
- Post-mutation lease errors and recovery write errors were treated as recoverable without restoring a consistent ownership or persistence boundary.

Fix:
- Retire rotated remote and bot sessions when the replacement lease cannot be acquired.
- Replace provider tool identities with stable per-run opaque aliases.
- Defer interrupted tombstones when metadata repair fails, emit a warning, and retry on the next bind.
- Add focused regression coverage for each failure mode.

Verification:
- go test ./internal/cli ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime
- go test -race ./internal/cli ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime
- go test ./internal/agent ./internal/control ./internal/serve ./internal/boot
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

* fix(machine): harden IDs and preserve active runtimes

Problem:
- Runtime profile and bot routing switches could close controllers that still owned pending prompts or background jobs.
- Machine session IDs used an unkeyed hash of guessable transcript branch names, allowing offline enumeration.

Root cause:
- Lifecycle guards only covered foreground turn state and did not serialize bot admission with runtime replacement.
- The public ID derivation had no installation-secret entropy.

Fix:
- Gate Remote close/profile operations and Bot routing rebuilds on running turns, pending prompts, and background jobs, with a second pre-swap check and an admission-serialized Bot switch.
- Initialize a private 32-byte per-install key under the Reasonix state directory with a cross-process lock and atomic 0600 write, derive 128-bit HMAC session IDs, and fail closed on corrupt identity state.
- Load the key before events-jsonl provider execution, document first-use initialization, and add focused lifecycle, concurrency, and privacy regressions.

Verification:
- go test ./internal/cli ./internal/remote/workbench/runtime ./internal/bot -count=1
- go test -race ./internal/remote/workbench/runtime ./internal/bot ./internal/cli ./internal/jobs -count=1
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- cd desktop && go test ./... -count=1
- ./scripts/cache-guard.sh
- git diff --check

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

* fix(machine): bind lifecycle repair to live ownership

Problem:
Startup subagent cleanup could interrupt work owned by another process, repair authorization survived lease handoff windows, and hook machine output labeled unusable entries active.

Root cause:
Subagent cleanup rewrote a shared directory without parent lease ownership, one registry mixed pending and active lease generations, and hook status did not reuse actual loader and matcher semantics.

Fix:
Acquire each parent transcript lease before stale subagent repair, publish only active lease generations and revoke them before unlock, and classify hook executability with shared event matcher semantics. Add deterministic cross-process, controlled handoff, invalid-hook, and compatibility regressions.

Verification:
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- env -u DEEPSEEK_API_KEY go test -race ./internal/agent ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime ./internal/cli ./internal/serve -count=1
- cd desktop && env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

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

* fix(machine): verify projected runtime state

Problem:
- task JSON could report missing or failed artifacts as complete
- plugin context paths that only existed could appear executable
- session help described opaque machine IDs as branch IDs

Root cause:
- persisted metadata and filesystem readiness were projected separately
- hook status and diagnostics used existence-only checks
- localized usage text retained an earlier identifier name

Fix:
- fail closed unless a terminal job has a readable regular persisted artifact without an artifact error
- share plugin context usability checks across machine status and diagnostics
- correct machine session ID help in all supported locales and add regressions

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime ./internal/cli ./internal/serve
- go test ./... (desktop)
- go vet ./...
- ./scripts/cache-guard.sh

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-24 09:12:24 +08:00
SivanCola 0f42b4b650 feat(agent): Planner and sub-agent trusted MCP via stable use_capability / Planner 与子 Agent 安装即信任 MCP (#6865)
* feat(agent): trust installed MCP in Planner and task sub-agents via stable use_capability

Problem:
The dual-model Planner and task/fleet children could not reliably query installed
MCP servers (e.g. databases) without readOnlyHint or dynamic mcp__ schemas, so
plans and delegated research were often ungrounded. Issue #6854.

Root cause:
Planner and strict/ordinary child registries either excluded MCP or required
readOnlyHint, and MCP tools entered provider-visible schemas so connect churn
broke prompt cache. Delivery alone had use_capability.

Fix:
- NewPlannerAgent with PlannerMCPExecution: authorized non-destructive MCP via
  use_capability without readOnlyHint; destructive left for Executor.
- Session-shared MCPCapabilityRuntime (Host/specs/connections) with per-agent
  use_capability frontends (isolated ledger/audit).
- Ordinary task/fleet children get full proxy (list/inspect/call); destructive
  follows normal writer permissions.
- Strict read-only children share proxy schema/reuse but keep authorized +
  readOnlyHint + non-destructive execution.
- Strip direct mcp__* from Planner and sub-agent registries; convert profile
  MCP allowlists to capability IDs; action=list for discovery.
- NonDestructiveMCPExecutionIntent re-check before tools/call; Desktop MCP
  tool card + docs.

Verification:
- go test ./internal/agent ./internal/boot ./internal/control ./internal/plugin ./internal/capability
- ./scripts/cache-guard.sh
- pnpm --dir desktop/frontend typecheck
- git diff --check

Fixes #6854

* fix(agent): close trusted MCP auth, connect gate, and review proxy gaps

Problem:
PR #6865 left three P1 boundaries incomplete: deferred MCP targets treated
!RequireLaunchApproval as authorized (bypassing Spec.Authorized), authorized
mcp_connect__* still entered the ordinary permission Gate, and review/read_only_skill
children never received the shared capability runtime or use_capability allowlist.

Root cause:
Connect helpers invented trust instead of using ServerAuthorized(); lifecycle
connect targets were excluded from the installed-MCP fast path; boot skill
runners still called registry builders without capRuntime and builtin review
AllowedTools omitted use_capability.

Fix:
- Deferred MCP targets and ensureServerTools use only Spec.ServerAuthorized().
- Authorized MCP tools and lifecycle connects share the explicit-deny-only gate.
- Wire capRuntime into read_only_skill and subagent skill runners; add
  use_capability to builtin review/security-review allowlists.
- Regression tests for zero-start unauthorized non-project MCP and trusted
  first connect under deny-all ordinary gates.

Verification:
- go test ./internal/agent ./internal/skill ./internal/plugin ./internal/boot

* fix(agent): stabilize restricted proxy prefix and isolate planner ledger

Problem:
Restricted use_capability proxies embedded dynamic capability IDs in their
Description, so MCP install/tool-list changes altered provider-visible tool
bytes (cache churn). Delivery dual-model still shared Executor use_capability
and ledger with the Planner. Restricted action=list leaked every configured
server. An empty if in subagent_registry_test triggered staticcheck SA4006.

Root cause:
Allowlist text was baked into tool descriptions; PlannerToolRegistry copied
parent use_capability before the clone branch; attachSubagentCapabilityProxy
returned early when a parent proxy was already present; list delegated to the
unfiltered frontend.

Fix:
- Keep restricted proxy description/schema identical to the base proxy; enforce
  allowlists only in host checks and filtered list results.
- Always strip and reinstall an isolated use_capability frontend for sub-agents
  and the Planner; give the Planner its own ledger and audit in boot.
- Filter list output to allowlisted servers only.
- Assert FilterReadOnlyRegistry keeps authorized RO MCP; add stability and
  isolation regression tests.

Verification:
- go test ./internal/agent ./internal/boot ./internal/skill
- git diff --check

* fix(agent): fail-closed restricted MCP list filtering

Problem:
Malformed allowed-tools entries such as "mcp-server:" produced a non-empty
capability allowlist but an empty server set, so filterCapabilityListResult
returned the unfiltered inventory. Unreadable list payloads also passed through.

Root cause:
Incomplete capability IDs were accepted literally, and empty-server / parse
errors used fail-open returns.

Fix:
- Accept only complete mcp-server:<name> and mcp-tool:<server>/<tool> IDs.
- Skip installing a restricted proxy when no servers resolve from the allowlist.
- Empty server sets and JSON parse/encode failures return an empty servers list.
- Regression tests for malformed IDs, empty servers, and bad list payloads.

Verification:
- go test ./internal/agent -run 'TestRestricted|TestMalformed|TestFilterCapability|TestPlannerTool|TestCustomProfile'

* fix(ui): show resolved MCP capability effects

Problem:
The stable use_capability proxy made Desktop and CLI present MCP calls only by the proxy surface. Writer MCP calls could be grouped as quiet research, and terminal users saw the implementation-level proxy name instead of the selected capability.

Root cause:
Reasonix resolved the real MCP target only inside execution. That target name, capability ID, and read-only classification were not carried through durable session history, frontend events, or Remote history projections.

Fix:
- Persist optional resolved target metadata while keeping the provider-visible tool call unchanged.
- Emit deterministic same-ID dispatch refreshes after MCP resolution and carry the same classification on tool results.
- Classify Desktop MCP cards by the resolved effect, fail closed for legacy history, and preserve the metadata across Local and Remote reloads.
- Render use_capability as MCP(capability_id) in the CLI TUI and headless output.
- Add concurrency, serialization, history, protocol, Desktop, and CLI regression coverage.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test
- go test -race ./internal/agent -run 'TestResolvedCapabilityDispatchRefreshesWriterClassification|TestResolvedCapabilityRefreshesParallelCallsInProviderOrder|TestUpdateToolCallResolutionPersistsAfterMidTurnSnapshot'
- ./scripts/cache-guard.sh
- go run ./cmd/remote-protocol-gen -check -root .
- git diff --check

* fix(agent): close trusted MCP proxy safety gaps

Problem:
Restricted sub-agent MCP allowlists could reveal sibling tool schemas, proxy-resolved writers could execute concurrently, and Balanced Executors could not directly call capability IDs discovered by their Planner.

Root cause:
Tool grants were widened to server IDs, batching trusted the proxy's read-only surface before resolving the real target, and dual-model Balanced installed use_capability only on the Planner registry.

Fix:
Keep tool grants exact and filter concrete tool inspection, serialize use_capability for every agent, and install isolated stable proxy frontends on both Balanced Planner and Executor while sharing the MCP runtime. Update bilingual contracts and add focused regressions.

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

* fix: enforce trusted MCP runtime boundaries

Problem:
Path-scoped sub-agents could resolve use_capability into unscoped MCP writers, and the stable MCP proxy kept boot-time specs after add, update, disable, or removal.

Root cause:
use_capability was classified as statically read-only before dynamic resolution, while MCPCapabilityRuntime and capability routing copied immutable boot snapshots that were not synchronized with controller lifecycle changes.

Fix:
Keep the provider-visible proxy contract stable while restricting explicit write_paths tasks to proven read-only MCP targets. Make each controller runtime an authoritative, race-safe MCP inventory, synchronize CLI/Desktop lifecycle changes and shared-host tabs, clear stale live tools, and route from live runtime snapshots.

Verification:
go test ./internal/agent ./internal/boot ./internal/control ./internal/plugin
go test -race ./internal/agent ./internal/control
env -u DEEPSEEK_API_KEY go test ./...
cd desktop && go test ./...
go vet ./...
./scripts/cache-guard.sh
git diff --check

* fix(agent): bind trusted MCP dispatch to live runtime identity

Problem:
Planner and stable capability frontends could reuse a same-name MCP client from another controller or stale configuration. Plan mode also blocked authorized opaque non-destructive MCP, lifecycle snapshots could mix generations, and legacy Desktop history lost built-in reader classification.

Root cause:
Shared Host reuse checked only the server name, resolved targets were not bound to the controller's current enabled/authorized spec, lifecycle mutations were not linearized with tools/call, and routing read configuration and live tools in separate snapshots. Documentation also described ordinary sub-agent MCP as using normal writer permission prompts even though installation is the trust boundary.

Fix:
- Compare complete host-local MCP runtime identity and require exact identity for Host/tool reuse.
- Bind resolved MCP targets to the live runtime and re-check enablement, authorization, identity, and destructive safety before dispatch.
- Linearize connect/call against update, disable, and removal; return atomic catalog/live-tool snapshots.
- Preserve Planner Plan-mode access to authorized non-destructive MCP without readOnlyHint and safely complete connected server-directory calls.
- Restore legacy Desktop reader classification and align bilingual contracts with explicit-deny-only installed MCP trust and direct-Executor cache limits.
- Add identity, shared-Host, real opaque Planner, revocation, concurrency, and frontend regressions.

Verification:
- go test ./internal/agent ./internal/plugin ./internal/control ./internal/boot -count=1
- go test ./internal/agent -run 'TestPlannerPlanModeExecutesAuthorizedOpaqueMCPThroughRuntime|TestRuntimeDisableLinearizesWithInFlightMCPDispatch|TestMCPCapabilityRuntimeConcurrentUpdatesAndSnapshots' -count=1
- go test ./internal/plugin -run TestMCPRuntimeSpecMatchesExactHostIdentity -count=1
- ./scripts/cache-guard.sh
- pnpm --dir desktop/frontend exec tsx src/__tests__/tool-subject.test.ts
- pnpm --dir desktop/frontend typecheck
- git diff --check

* fix(agent): remove obsolete MCP runtime wrapper

Problem:
The latest PR head failed golangci-lint because ensureServerTools remained as an unused private wrapper after every dispatch path moved to the identity-bound ensureServerToolsForSpec helper.

Root cause:
The runtime-identity refactor replaced all callers but left the compatibility wrapper behind. Go compilation and vet allow unused methods, while CI's unused analyzer correctly rejected it.

Fix:
Delete the dead wrapper so all connect paths continue to require the already-authorized current runtime spec explicitly.

Verification:
- go test ./internal/agent -count=1
- go vet ./...
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- git diff --check

* fix(agent): preserve MCP images and route IDs

Problem:
The first use_capability call to an unconnected MCP server dropped structured image results, and Planner transient routing rewrote concrete MCP capability IDs to source targets that use_capability cannot call.

Root cause:
onDemandMCPTool exposed only the text Execute interface, so Agent dispatch could not select ExecuteWithImages. RenderTransientBlock applied legacy source rewriting before accounting for Planner MCP proxy routing.

Fix:
Give the deferred MCP target a shared image-aware execution path that retains the runtime authorization lock through tools/call. Keep concrete MCP IDs for capability-proxy routes while preserving ordinary source routing for non-MCP capabilities. Add full Agent-path image coverage and focused route rendering regressions.

Verification:
- go test ./internal/agent ./internal/capability ./internal/control ./internal/boot
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/agent -count=1
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check
2026-07-24 08:21:22 +08:00
SivanCola 8eb9707a3f Fix rich composer caret stability / 修复富文本输入框光标跳尾 (#6872)
* fix(desktop): stabilize rich composer caret after invocation tags

Problem:
After selecting a skill or plugin invocation, mid-text edits in the desktop
rich composer often jumped the caret to the end of the draft on the first
keystroke, especially on Windows WebView2.

Root cause:
DOM→model counted user text that landed inside the NBSP caret anchor, but
model→DOM selection restore skipped the entire anchor. When selection was
temporarily unavailable, the code also faked the caret at text.length.

Fix:
Unify DOM walk rules for model reads, selection reads, and selection restore
so invocation chips stay zero-length while anchor body text remains editable.
Restore both selection ends, keep afterInvocationId behavior, and recover the
insertion point from a beforeinput snapshot when the browser drops selection.
Add focused rich-composer selection regressions and an Unreleased note.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/rich-composer-selection.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend build

* fix(desktop): recover rich caret after IME blackout and data-null repeats

Problem:
Two caret-recovery edges remained after the initial #6868 fix. IME
compositionend could restore the pre-composition offset when WebView2 dropped
selection, and data-null inserts of a repeated character (e.g. "aaa" → "aaaa"
at offset 1) recovered to the end via maximal prefix/suffix matching.

Root cause:
compositionstart never froze an edit snapshot while composing beforeinput was
skipped, so compositionend had no mid-edit baseline. recoverSelectionAfterEdit
also trusted a longest common prefix that claims the whole string when the
inserted run matches surrounding characters.

Fix:
Snapshot model text and caret on compositionstart; keep that baseline through
composition. Reconstruct post-edit carets with a selection-anchored head/middle/
tail splice before falling back to prefix/suffix, and prefer that recovery when
a live selection is parked at the end after a mid-text edit. Cover both edges
in rich-composer-selection tests.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/rich-composer-selection.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm --dir desktop/frontend typecheck

* fix(desktop): preserve late WebView2 IME commits

Problem:
Windows WebView2 can dispatch compositionend before the committed IME DOM and final non-composing input are visible. Synchronizing the controlled model at compositionend publishes the stale pre-composition text and erases the selected candidate.

Root cause:
The composer assumed every Chromium host exposed the committed DOM by compositionend. Deferring every compositionend avoided the WebView2 blackout but regressed browsers and submit paths that require the committed text synchronously.

Fix:
Keep the pre-composition snapshot, synchronize immediately when compositionend already exposes changed DOM, and defer only while the DOM still matches the snapshot. Let the following non-composing input own late WebView2 commits, with a next-frame fallback and cleanup cancellation. Cover both event orders explicitly.

Verification:
- rich-composer-selection: 80 passed
- composer-goal-toggle: 171 passed
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend build
- git diff --check
2026-07-24 02:20:21 +08:00
217f768ce1 Fix provider model state without changing session defaults / 修复 Provider 模型状态并保持会话默认边界 (#6881)
* fix(desktop): sync provider model state

* fix(desktop): preserve provider state boundaries

Problem: The source PR made per-session model switches rewrite the global default model and silently disabled existing provider balance endpoints.

Root cause: Session-local model state and user-global defaults were treated as one setting, while the stale-balance race fix was coupled to an unrelated provider capability restriction.

Fix: Keep hot model switches tab-local, retain existing balance_url behavior, and add regression coverage proving the global default and sibling tabs remain unchanged.

Verification: go test ./... (desktop); pnpm test; pnpm build; focused config/frontend tests; ./scripts/cache-guard.sh; git diff --check.

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

* fix(desktop): serialize provider model switches

Problem:
Rapid model selections could let an older completion repopulate stale wallet state, while no-op or failed switches discarded the last known balance.

Root cause:
Balance request generations did not order SetModelForTab calls or distinguish stale model-switch completions. The current model also remained actionable without accounting for an in-flight switch.

Fix:
Serialize model switches per tab, track switch ownership and successful backend transitions, restore cached balance only when safe, ignore failed balance confirmations, make settled current-model selection a no-op, and invalidate provider state when tabs are discarded.

Verification:
- pnpm test
- pnpm build
- go test ./...
- go test -race . -run TestSetModelForTab -count=1
- ./scripts/cache-guard.sh

* fix(desktop): reconcile provider state across races

Problem:
Queued model fallback, cross-tab pending picks, and failed tab closes could leave provider UI state stale or trigger unnecessary switches.

Root cause:
Failure reconciliation omitted model metadata, pending pick ownership was component-global, and close invalidation ran before backend success.

Fix:
Refresh metadata after an older queued success, scope pending picks per tab, and invalidate provider generations only after a successful close. Add regression coverage for all three overlaps.

Verification:
pnpm test
pnpm build
pnpm exec tsc --noEmit -p tsconfig.test.json
go test -race . -run TestSetModelForTab -count=1

* Fix failed model switch reconciliation

Problem:
Rapid queued model switches could lose the pre-switch balance when every rebuild failed, and a failed selection could remain optimistically marked as current in the model picker.

Root cause:
Each queued attempt captured the already-cleared balance independently, while the picker did not receive or sequence the controller switch outcome.

Fix:
Keep one fallback balance for each per-tab switch queue, return an explicit switch result through App and Composer, and roll back plus reload the catalog only for the latest failed pick. Invalidate pre-click catalog requests so stale results cannot overwrite a retry.

Verification:
- pnpm test:all
- pnpm build
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- ./scripts/cache-guard.sh
- git diff --check

---------

Co-authored-by: par73e <[email protected]>
Co-authored-by: par73e <[email protected]>
2026-07-23 23:46:55 +08:00
SivanCola 035f92897f fix(desktop): support authorized Debian package updates / 支持 Debian 安装包应用内授权更新 (#6866)
* fix(desktop): support authorized Debian package updates

Problem:
Ubuntu users who installed the desktop .deb could not update in app because the updater could not replace root-owned system files.

Root cause:
Linux installs were treated uniformly as writable portable layouts, and the release manifest exposed only the tarball updater artifact.

Fix:
Detect dpkg-owned installs, publish signed native .deb metadata, and install upgrades through a packaged Polkit helper that re-verifies ownership, signatures, package identity, architecture, and version before delegating to apt. Preserve the portable tarball path and add user-facing progress, metrics, tests, and recovery documentation.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:updater
- pnpm --dir workers/crash-report typecheck
- pnpm --dir workers/crash-report test
- Linux amd64 helper test binary cross-compilation
- git diff --check

* fix(desktop): recover from updater mode changes

Problem:
The desktop CI failed on two unused updater helpers, and the UI could remain stuck in downloading or authorizing when install mode or manifest availability changed between updater steps.

Root cause:
The backend opened the manual download page and returned nil after reclassification, while the frontend treated nil as an in-flight operation with no terminal state. Two obsolete helper functions also remained after the refactor.

Fix:
Return and emit a typed manual-update error before opening the download page, preserve the manual fallback action in the shared updater state, add authorization/install/cancellation/reclassification coverage, and remove the unused functions.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:updater
- pnpm --dir desktop/frontend build
- pnpm --dir workers/crash-report typecheck
- pnpm --dir workers/crash-report test
- Linux amd64 update-helper test binary and production build
- git diff --check

* Fix Linux package smoke checks under pipefail

Problem:
Linux .deb contract smoke checks could fail on valid packages when the producer emitted more data after grep found its match.

Root cause:
The build script combined set -o pipefail with grep -q. Early grep exit could close the pipe and make dpkg-deb terminate with SIGPIPE, turning a successful contract match into a failed pipeline.

Fix:
Consume the complete dpkg-deb output with non-quiet grep redirected to /dev/null, and add a packaging guard that rejects the unsafe early-exit patterns.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -run TestDesktopPackagesPreserveNativePlatformLaunchers -count=1 .
- pnpm --dir desktop/frontend test:updater
- bash -n scripts/desktop-build.sh
- git diff --check
2026-07-23 23:22:44 +08:00
SivanCola 925cb07bae Bound Auto recovery by Episode and exit Plan without executing / 按 Episode 限制 Auto 恢复并支持退出计划 (#6871)
* fix(auto): scope recovery failures to exact operations

Problem:
Auto Guard treated consecutive execution failures and reviewer rejections as task-wide state. After repeated test failures, unrelated workspace writes could remain blocked, and revising the proposal or starting a new ordinary turn did not reliably clear the latch.

Root cause:
Failure and reviewer counters were keyed only by the root task. Recovery observations carried no turn scope, revise kept the active failure, and the display surfaced only a generic Auto Guard error.

Fix:
Scope failure budgets to the exact operation and recovery scope, rotate episodes for different operations and ordinary turns, clear them on revise or when leaving Auto, retain stable Goal scopes across recovery persistence, scope reviewer rejection budgets to each proposal, and surface actionable blocker text. Update the English and Chinese approval-mode documentation and add compatibility coverage.

Verification:
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go test -race ./internal/recovery ./internal/control -count=1
- cd desktop && go test . -run 'Recovery|AutoGuard' -count=1
- ./scripts/cache-guard.sh
- go vet ./internal/recovery ./internal/agent ./internal/control

* fix(plan): add explicit non-executing exit

Problem:
Plan approval offered execution or revision, but no direct way to leave Plan without running the proposal. Rejecting the prompt kept Plan active, so desktop and CLI users could become stuck in the planning workflow.

Root cause:
The desktop already had an exit callback but did not expose it as a decision. The CLI represented Plan as a two-choice boolean approval and only cleared Plan after approval.

Fix:
Add a third exit-without-executing action on desktop and CLI, switch both UI and controller state out of Plan before rejecting that action, preserve revise and n/Esc keep-planning behavior, localize all supported languages, update docs, and add click, keyboard, duplicate-submit, banner, and state-sync coverage.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm typecheck
- pnpm check:css
- pnpm build
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./internal/cli ./internal/i18n
- git diff --check

* docs(plan): clarify cross-client shortcuts

Problem:
The approval guide described Desktop click behavior and Esc handling as if they applied identically to the CLI, leaving the new CLI Plan decision ambiguous.

Root cause:
The Plan action documentation was written before the CLI received its explicit three-action workflow.

Fix:
Document Desktop and CLI activation separately, state that CLI n/Esc keeps planning during a pending Plan confirmation, and scope the generic Esc stop behavior accordingly in English and Chinese.

Verification:
- git diff --check

* test(plan): pin localized three-action contract

Problem:
The CLI Plan workflow depends on three numbered actions in every supported locale, but tests only checked the English labels and non-empty catalog fields.

Root cause:
Localization completeness did not assert the semantic action count or the execute, revise, and exit-without-executing labels.

Fix:
Add a catalog-level regression test that requires exactly three numbered Plan actions and the expected semantic labels in English, Simplified Chinese, and Traditional Chinese.

Verification:
- go test ./internal/i18n -count=1
- git diff --check

* fix(auto): bound recovery by execution episode

Problem:
Auto recovery could still feel like a sticky lock: repeated Maven/test failures
blocked progress, different parameter retries could avoid the same-operation
cap, and restart or mode hydration risked re-arming temporary failure state.
Users needed a low-friction path where this turn auto-recovers with a hard
ceiling, then the next turn recovers automatically.

Root cause:
Failure and reviewer budgets mixed TaskScope with temporary execution state,
kept a single active failure rather than Episode-scoped counters, re-armed
locks from sidecar snapshots, and lacked a generation barrier for late tool
results after mode switches. There was no turn-level stop with a summarize-only
finalization path or a wire-compatible recovery_paused outcome.

Fix:
- Separate host-owned EpisodeID (failure/reviewer/stop budgets) from TaskScopeID
  (Goal and explicit task grants).
- Fixed budgets with no settings: 3 identical operation failures, 6 Episode
  execution failures since last real progress, 3 cumulative reviewer rejects,
  3 re-submissions of stopped operations.
- Successful mutation/verification clears no-progress budgets; diagnostic reads
  do not. Provider/cancel/user-reject/pre-exec blocks stay out of the budget.
- Gate generation invalidates stale observations; real mode changes rotate
  Episode and dismiss recovery waiters without approving mutations. Same-value
  mode replays are no-ops.
- Episode stop blocks remaining tools, offers one finalization round, then
  returns RecoveryPauseError with turn_done.outcome=recovery_paused (info UI,
  not send-failed).
- Persistence writes last_failure evidence only; restore never re-arms locks.
- Content-free metrics for stops, mode resets, and stale observations.

Verification:
- go test ./internal/recovery ./internal/agent ./internal/control ./internal/eventwire ./internal/cli -count=1
- go test -race ./internal/recovery ./internal/agent ./internal/control -count=1
- pnpm exec tsx desktop/frontend/src/__tests__/send-failed.test.ts
- ./scripts/cache-guard.sh
- go vet ./internal/recovery ./internal/agent ./internal/control ./internal/event
- git diff --check

* style(auto): format episode recovery changes

Problem:
The Episode recovery commit left three root-module Go files outside gofmt canonical formatting, causing the macOS and Ubuntu CI jobs to stop before vet, build, and tests.

Root cause:
The final Episode changes were committed without applying gofmt to all touched Go files.

Fix:
Run gofmt on the three files reported by the same command used in CI. The resulting diff is whitespace-only.

Verification:
- gofmt -l . excluding the independent desktop module returns no files
- git diff -w --exit-code
- git diff --check

* fix(auto): close Episode budget bypasses and lint gaps

Problem:
Episode hard limits could still be bypassed three ways: a successful
recovery summary returned nil so Goal auto-continue opened another Run
with a fresh finalization round; total failure and reviewer budgets were
per TaskID so each new sub-agent got a full ceiling; and a reviewer
Continue cleared cumulative rejects before any real mutation progress.
golangci-lint also failed on empty branches and unused helpers.

Root cause:
Finalization success treated the pause as a normal turn end; Episode
totals lived on taskRuntime; reviewRejects reset on Continue; dead
helpers and empty branches remained from the Episode refactor.

Fix:
- Return RecoveryPauseError after a successful summarize-only finalization
  so turn_done reports recovery_paused and Goal auto-continue stops.
- Keep exact-operation counters task-scoped; move Episode totals,
  reviewer rejects, stopped-op retries, hard stop, and finalization to a
  Gate-level episode budget shared by root and all sub-agents.
- Clear reviewer rejects only on real mutation/verification success, a
  new Episode, or revise — not on reviewer Continue or human continue.
- Remove unused helpers/empty branches and add regressions for shared
  sub-agent budgets and reject→continue→reject cumulative counting.
- Block Goal on RecoveryPauseError like FinalReadinessError.

Verification:
- go test ./internal/recovery ./internal/agent ./internal/control -count=1
- go test -race ./internal/recovery ./internal/agent ./internal/control -count=1
- go vet ./internal/recovery ./internal/agent ./internal/control
- gofmt clean on touched packages
- git diff --check

* fix(auto): keep Goal running after recovery pause

Problem:
RecoveryPauseError set the Goal to blocked. The next ordinary user message
opened a fresh Episode but lost the active Goal prompt and delivery scope, so
replying continue did not actually resume Goal work without an explicit resume.

Root cause:
goalShouldBlockOnError treated RecoveryPauseError like FinalReadinessError and
called stopGoal(GoalStatusBlocked). Only running Goals reinject model context
and delivery scope.

Fix:
Keep the Goal running after RecoveryPauseError so auto-continue stops but the
next real user turn reuses the same Goal and delivery scope. Reserve
GoalStatusBlocked for FinalReadinessError. Add a regression covering pause then
ordinary continue.

Verification:
- go test ./internal/control -run 'RecoveryPause|GoalReadiness' -count=1
- go test ./internal/control -count=1

* fix(recovery): present paused turns consistently

Problem:
Recovery pauses were informational in the Desktop React chat, but the Desktop host, bot bridge, Serve WebUI, TUI, metrics, and headless CLI still treated them as failures.

Root cause:
Those consumers classified turn completion from a non-nil error alone and ignored the recovery_paused outcome. The headless CLI also had separate result and exit-code classification paths.

Fix:
Honor recovery_paused across every client surface, keep ordinary errors unchanged, and centralize headless completion classification so notifications, JSON output, user messaging, and exit codes agree.

Verification:
go test ./internal/cli ./internal/serve ./internal/control ./internal/eventwire ./internal/agent ./internal/recovery -count=1
go test -race ./internal/cli ./internal/control -count=1
cd desktop && go test ./... -count=1
cd desktop && go vet ./...
pnpm exec tsx src/__tests__/send-failed.test.ts
./scripts/cache-guard.sh
go vet ./internal/cli ./internal/serve ./internal/control ./internal/event ./internal/eventwire
git diff --check
2026-07-23 20:15:25 +08:00
JesonChouandSivanCola 04c0d88f4d feat(desktop): expand syntax highlighting for more languages / 拓展更多种语言的语法高亮支持 (#6861)
* feat(desktop): expand highlight.js language set to 52 languages

Register 26 additional languages (from 26 to 52) covering common
ecosystems: lua, dart, groovy, objectivec, r, perl, fsharp, haskell,
elixir, clojure, erlang, julia, scss, less, latex, vim, nginx, apache,
protobuf, graphql, cmake, gradle, properties, coffeescript, matlab,
and vbnet.

Map XAML (.xaml) to XML highlighting since highlight.js has no native
XAML lexer and XAML is an XML dialect.

Extend EXT, ALIASES (lang.ts) and byExt (selectedTextContext.ts) tables
with corresponding file-extension and fence-tag aliases so all three
code-preview paths — Markdown fenced blocks, tool diffs, and the
workspace file viewer — resolve the correct language.

Closes #6825 (desktop portion).

* fix(desktop): correct jsx/tsx languageFor mappings to registered hljs names

languageFor() in selectedTextContext.ts mapped .jsx and .tsx to "jsx" and
"tsx", but highlight.js does not register languages under those names --
the actual registered names are "javascript" and "typescript". While the
immediate consumer (formatSelectionReference) works correctly because the
downstream highlightToHtml() passes through resolveLang() which handles
these aliases, the mapping is technically wrong and would break for any
future consumer that uses the languageFor return value directly without
alias resolution.

Fix: map jsx to "javascript" and tsx to "typescript" to match the
highlight.js registration names, making the function self-consistent.

* fix(desktop): unify syntax highlight language mapping

Problem:
- File previews and tool diffs could disagree for special filenames such as Dockerfile, Makefile, CMakeLists.txt, and .htaccess.
- Plan-revision fences replaced semantic tags such as TSX, HTML, TOML, and XAML with fallback lexer names.

Root cause:
- Path-to-language behavior was duplicated across independent tables, and extension-only matching could not recognize extensionless build and server configuration files.

Fix:
- Centralize semantic path mapping for both slash styles, extensions, and special basenames.
- Resolve semantic tags to highlight.js lexers only at the rendering boundary.
- Add regression coverage for mapped lexers, special filenames, UI consistency, and provider-visible fence tags.

Verification:
- pnpm test:typecheck
- pnpm test
- pnpm build
- ./scripts/cache-guard.sh
- git diff --check

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

* test(desktop): cover remaining special language paths

Problem:
- The shared path resolver supported httpd.conf and .makefile suffixes, but the focused path matrix did not exercise those branches directly.

Root cause:
- The initial regression matrix covered the lexer set and primary special basenames without one concrete path for each remaining matcher.

Fix:
- Add end-to-end path, tool-diff, file-preview, and lexer assertions for httpd.conf and module.makefile.

Verification:
- pnpm exec tsx src/__tests__/language-highlighting.test.ts
- pnpm test:typecheck
- git diff --check

* fix(desktop): cap chat syntax highlighting

Problem:
- Ordinary chat and tool code blocks always ran highlight.js, even when a block exceeded the workspace preview's 512 KiB or 20,000-line safety budget.

Root cause:
- The highlighting budget lived inside the line-numbered workspace viewer instead of the shared highlighting layer.

Fix:
- Move the byte and line limits into the shared highlighter.
- Measure ordinary blocks using UTF-8 bytes without allocating a duplicate buffer.
- Fall back to escaped plain text above either limit while preserving content and copy behavior.
- Reuse the same helper from workspace and ordinary code viewers.

Verification:
- pnpm exec tsx src/__tests__/line-number-code.test.tsx
- pnpm exec tsx src/__tests__/language-highlighting.test.ts
- pnpm test:typecheck
- pnpm test
- pnpm build
- ./scripts/cache-guard.sh
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-23 18:56:50 +08:00
c5940f6319 fix(desktop): Preserve transcript selection during plan revision / 修复计划修订焦点抢占 (#6466)
* fix: plan mode focus stealing - stabilize onRevisionActiveChange and avoid unnecessary re-renders

* fix(desktop): preserve transcript selection during plan revision

Problem:
The open plan-revision textarea reclaimed focus whenever its parent rerendered, clearing transcript text selections before users could copy them.

Root cause:
The focus effect depended on a callback prop whose identity could change during periodic App refreshes, so every callback change called focus again. The original patch also relied on suppressing tab metadata rerenders instead of making the approval owner resilient.

Fix:
Keep the App callback stable, focus the revision textarea only when revisionOpen changes, track the latest callback for close and unmount cleanup, remove the unrelated JSON.stringify polling dedupe, and add a regression that churns the callback while a transcript selection is active.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm typecheck
- ./scripts/cache-guard.sh
- Browser smoke test at http://127.0.0.1:5173/

* test(desktop): document plan revision rerender regression

Problem:
The focus regression test simulated callback identity churn without documenting why that is the relevant production trigger.

Root cause:
Periodic tab metadata refreshes rerender App, which historically recreated the revision-active callback and refocused the textarea.

Fix:
Document the parent-rerender trigger next to the regression step.

Verification:
- pnpm exec tsx src/__tests__/approval-modal-file-reference.test.tsx
- git diff --check

---------

Co-authored-by: SauronSkywalker <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-23 18:12:09 +08:00
mchenziyiandSivanCola d385432a08 fix: 支持 Review 结果作为 complete_step 证据 (#6762)
* fix: add review evidence for complete_step

* fix: accept dedicated review tool receipts

* fix(evidence): require fresh review receipts

Problem:
complete_step accepted any successful review/task receipt in the turn, including a review that predated a later mutation or a background task that had only started.

Root cause:
The review evidence matcher scanned the whole ledger without applying mutation ordering, changed-path coverage, or the structured review_report contract.

Fix:
Require review evidence to be fresh for the latest mutation and cover its changed result. Accept collected structured review reports, reject background starts, and define review evidence as activity completion while leaving blocking verdict enforcement to the delivery gate.

Verification:
- go test ./internal/evidence ./internal/tool/builtin -count=1
- ./scripts/cache-guard.sh
- go vet ./...
- go test ./internal/plugin -run TestLazyCacheMissAsyncSpawn -count=1
- env -u DEEPSEEK_API_KEY go test ./internal/provider/openai -count=1

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-23 17:49:49 +08:00
mj-leeand9710willy 4499687209 fix(site): correct docs sidebar active-section highlight (#6222)
The scrollspy built its list of sections from the sidebar links, which are
grouped by topic rather than page order, then picked the last section
scrolled past as active. That assumes the list is in document order, so the
highlight settled on the wrong item. Sort the sections by document position.

Clicking a link also swept the highlight through every section it passed,
because the spy runs on each frame of the smooth scroll. Pin the highlight to
the clicked section and release it on scrollend, falling back to the plain
jump where scrollend isn't supported.

Set aria-current on the active link so assistive tech can announce it.

Co-authored-by: 9710willy <[email protected]>
2026-07-23 14:59:22 +08:00
SivanCola 020bf6ebf7 fix(release): carry reviewed notes into recoveries (#6843)
Problem: Stable recovery publishers failed when an immutable candidate predated its reviewed release-note entry. A partially successful run also had no safe way to retry only failed channels.

Root cause: CLI and Desktop rendered notes from the candidate checkout, while preflight validated notes from the protected control plane. The orchestrator always invoked every publisher during recovery.

Fix: Upload the preflight-rendered notes and consume that exact artifact in orchestrated CLI/Desktop publishers. Add opt-out channel switches for manual recovery while retaining public postflight verification for skipped channels.

Verification: bash scripts/release-workflows.test.sh; node scripts/release-notes.mjs render --version v1.17.19 --output /tmp/reasonix-release-notes-v1.17.19.md; git diff --check.
2026-07-22 15:53:46 +00:00
SivanCola 3bedc054a3 docs(release): prepare v1.17.19 notes (#6841)
Add reviewed bilingual product notes for the stable recovery workflow after the initial tag preflight found the catalog entry missing.
2026-07-22 15:32:14 +00:00
SivanCola b8f715b546 Drain lazy MCP cache writes on close / 关闭时等待 Lazy MCP 缓存写入 (#6840)
* fix(plugin): drain lazy schema writes on close

Problem:
Formal release tests intermittently failed while removing plugin cache temp directories because a lazy schema cache writer was still running.

Root cause:
Lazy MCP startup launched SaveCachedSchema in an untracked goroutine, so Host.Close could return before the detached write finished.

Fix:
Register lazy schema persistence with the Host background-write WaitGroup and add a deterministic close-wait regression test.

Verification:
- focused lazy lifecycle tests, 50 runs
- go test ./internal/plugin -count=5
- go test -race ./internal/plugin
- two consecutive REASONIX_RELEASE_CACHE_GUARD=1 go test ./... -count=1

* fix(plugin): close remaining cache write races

Problem:
PR review found that the synchronous lazy cache-hit path could still write after Host.Close returned, while Windows CI intermittently observed the SSE concurrency assertion before its handler published the active count.

Root cause:
The cache-hit path ended its deferred-spawn lifecycle before persisting the schema, and the SSE test closed its started signal before updating maxPosts.

Fix:
Queue cache-hit schema persistence before ending the deferred spawn, and publish the SSE started signal only after the concurrency counters are visible.

Verification:
- focused lazy lifecycle tests, 50 runs
- SSE concurrency regression test, 5000 runs
- focused race tests, 20 runs
- go test -race ./internal/plugin
- go test ./internal/plugin -count=10
- REASONIX_RELEASE_CACHE_GUARD=1 go test ./... -count=1
- go vet ./... && go build ./...
- Windows/amd64 plugin test cross-compilation
2026-07-22 23:01:27 +08:00
SivanCola 5778e919d5 test(frontend): restore release typecheck gate (#6837)
Problem:
The formal frontend test gate failed TypeScript checking before the 1.17.19 release.

Root cause:
The MCP install mock dereferenced optional Wails bindings without narrowing them, and an obsolete textarea helper remained after the test flow changed.

Fix:
Narrow the mocked App binding before delegation and remove the unused helper.

Verification:
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- pnpm test:all
- pnpm build
2026-07-22 14:27:03 +00:00
erphenheimerandSivanCola 9536edce8a fix(todo): add canonical todo state to MetaForTab so frontend reads authoritative server-side state / 修复待办面板状态:MetaForTab 传规范待办状态 (#6774)
* fix(todo): add canonical todo state to MetaForTab so frontend reads authoritative server-side state

The frontend todoEntry previously only parsed the last todo_write tool call's args from state.items, which could show stale state when:
- Synthetic host-advance events from complete_step were not yet committed to items
- Session reload caused compactArchivedToolItems to truncate non-canonical todo args

Fix: add CanonicalTodos field to Meta struct, populate it from the Controller's authoritative todoState in MetaForTab, so the frontend always has access to the correct canonical task list independent of tool call event processing.

* fix(todo): frontend reads canonicalTodos from Meta for authoritative task list

The frontend todoEntry previously only parsed the last todo_write tool
call's args from state.items. Add fallback: if state.meta.canonicalTodos
(from MetaForTab) is available and non-empty, prefer it over parsing
tool call args. This ensures the frontend always shows the correct
canonical todo state even after session reload or when synthetic
host-advance events are not yet committed to items.

* fix: normalize app.go line endings to LF for gofmt compliance

* fix: gofmt alignment + types.ts Todo type import

- Fix gofmt alignment issue in Meta struct: CanonicalTodos field
  tag alignment (extra spaces before backtick)
- Add import type { Todo } from './tools' to types.ts so the
  canonicalTodos field in Meta interface can reference Todo[]
  TypeScript type

* fix: add missing time import

* Revert "fix: add missing time import"

This reverts commit a21b24cadca43796933d520788cecacf4aac8657.

* fix(todo): preserve canonical state across session transitions

Problem:
Canonical todo status-only updates could be discarded by frontend meta equality, and session resets could retain stale todos. Empty canonical lists were also indistinguishable from an unavailable controller snapshot.

Root cause:
The Meta contract used omitempty on a slice, sameMeta ignored canonicalTodos, optimistic tab metadata dropped it, and session resume or rewind paths did not refresh authoritative todo metadata after resetting transcript state.

Fix:
Preserve unavailable, empty, and non-empty todo states across the Wails boundary; compare canonical todo content in meta equality; clear session-scoped todo metadata on reset; refresh it after session transitions; and add focused wire, reducer, visibility, new-session, and resume regression coverage.

Verification:
- cd desktop && go test ./...
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:typecheck
- git diff --check

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

* fix(todo): guard canonical state across runtimes

Problem:
Remote workbench sessions dropped canonical Todos, and a Meta response started before a session transition could restore the previous session's Todo state after the reset.

Root cause:
Frontend Meta reads had no per-tab latest-request sequencing tied to session generations, while the native workbench projection ignored SessionSnapshot.Todos.

Fix:
Centralize guarded Meta reads, invalidate them on every session transition, project validated remote Todos with unavailable/empty/value semantics, and cover both race and remote projection paths.

Verification:
- go test ./...
- pnpm test
- pnpm typecheck
- pnpm test:typecheck
- pnpm build

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-22 22:25:20 +08:00
JesonChouandSivanCola 06e41c7826 fix(plugin): MCP stdio subprocess CWD resolves against wrong project root (#6778) / 修复 MCP 子进程工作目录解析到错误项目根目录 (#6819)
* fix(plugin): MCP stdio subprocess CWD falls back to WorkspaceRoot

When Spec.Dir is empty (the default for user-configured plugins), the
stdio subprocess inherits the parent process CWD. In the Wails desktop
app running multiple projects, that CWD is the first project listed in
desktop-projects.json, not the directory containing .mcp.json. Relative
config paths (e.g. --config-file ssh-config.json) then resolve against
the wrong project root.

Fix: newStdioTransport now falls back to Spec.WorkspaceRoot when Dir is
empty, so every project-scoped MCP server resolves relative paths
against the correct project root.

Fixes #6778.

* fix(plugin): align project MCP cwd authorization

Problem: Project-scoped stdio MCP cwd fallback could authorize a different relative executable than the one launched, while also changing cwd for user-installed servers.

Root cause: Launch and authorization resolved relative commands against different directories, and WorkspaceRoot was treated as process cwd without checking project provenance.

Fix: Centralize stdio working-directory selection, limit the fallback to repository-declared servers, resolve relative executables against that directory before hashing and launching, and bind the directory to the launch identity.

Verification: go test ./internal/plugin -count=1; go test ./internal/boot -count=1; env -u DEEPSEEK_API_KEY go test ./...; go vet ./...; Windows and Linux test cross-compilation; git diff --check.

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-22 22:09:17 +08:00
SivanCola 31f037d07a feat(mcp): make trusted servers zero-config and reliable / 让可信 MCP 服务零配置且可靠可用 (#6829)
* fix(mcp): make trusted servers zero-config and reliable

Run authorized stdio servers on the host by default while retaining private state directories, bounded lifecycle cleanup, lazy schema restoration, and single-flight startup.

Add durable activation, transactional readiness and rollback, CLI candidate probing, and inherited MCP access for child agents. Include a real chrome-devtools-mcp release smoke test.

* feat(desktop): reduce MCP install to one input

Accept a command, URL, or JSON definition in the common install path, surface structured readiness and authentication states, and keep transport and timeout controls in the advanced edit flow.

* feat(desktop): unify MCP quick and advanced setup

Problem: The zero-config MCP installer removed useful configuration controls and left the add-server page feeling too sparse.

Design: Keep quick install as the default, restore manual and JSON modes in the same editor, carry pasted definitions into advanced modes, and make the automatic install outcomes explicit.

Verification: npm run typecheck; npx tsx src/__tests__/capabilities-panel-actions.test.ts; npm run check:css; npm run build; git diff --check.

* fix(plugin): remove obsolete deferred wrapper

Problem: golangci-lint reported Host.addDeferred as an unused private method.

Root cause: Lazy startup now calls EnsureConnectedWithLifecycle directly, but the old wrapper remained after the single-flight migration.

Fix: Remove the dead wrapper and its stale comment without changing runtime behavior.

Verification: go test ./internal/plugin; go test -race ./internal/plugin; scripts/check-cache-impact.sh; git diff --check.

* fix(desktop): remove obsolete MCP intent helper

Problem: The desktop golangci-lint job reported mcpStartIntent as an unused private function.

Root cause: Legacy startIntent fields are now derived in withPluginConfigInWorkspace, but the previous helper remained after the activation-state migration.

Fix: Remove the dead helper while preserving the derived compatibility fields.

Verification: cd desktop && go test ./...; cd desktop && go vet ./...; scripts/check-cache-impact.sh; git diff --check.

* fix(mcp): preserve trust and on-demand boundaries

Problem: Explicit subagent tool allowlists could be widened by MCP inheritance, host-session readiness probes owned successful stdio processes, durable enable still connected eagerly, activation updates could clobber each other, quick names followed trailing arguments, and Windows asserted POSIX mode bits.

Root cause: MCP inheritance bypassed the normal registry filter; the short probe context was reused as process lifetime; enable reused the live-connect API; activation stores only locked per instance; name derivation scanned from the command tail; and the permission test was not platform-aware.

Fix: Keep explicit allowlists authoritative while preserving default MCP inheritance, split ExtraPlugin call and lifetime contexts, add cache-backed on-demand registration, serialize activation transactions with a cross-process lock, derive quick names from launcher operands in desktop and CLI, and scope mode-bit assertions to POSIX.

Verification: go test -race ./internal/agent ./internal/boot ./internal/control ./internal/config; env -u DEEPSEEK_API_KEY go test ./...; cd desktop and run go test ./... plus go vet ./...; frontend MCP actions, typecheck, build, and CSS checks; go vet ./...; ./scripts/cache-guard.sh; git diff --check.
2026-07-22 21:42:47 +08:00
SivanCola d9dd4aa0e5 fix(registry): re-review publisher updates / 发布者更新重新进入审核 (#6830)
* fix(registry): re-review publisher updates

Problem:
Publishers could replace the source, manifest, or metadata of an active package while it stayed live and retained its verified badge.

Root cause:
Registry moderation was triggered only when the public package kind changed, although every accepted republish creates a new immutable package version.

Fix:
Return every non-admin package update to pending review, clear verification, and cover active, hidden, rejected, and trusted-admin update paths.

Verification:
- workers/crash-report: npm test (35 passed)
- workers/crash-report: npm run typecheck
- git diff --check

* fix(registry): fence approvals to reviewed revision

Problem:
A stale moderation page could approve a package version submitted after the reviewer loaded the queue.

Root cause:
Approval requests carried only the slug and action, while both admin backends performed unconditional status updates.

Fix:
Bind approvals to the reviewed version, update timestamp, and status with one atomic D1 conditional update. Stale or old-page requests now fail closed across both admin interfaces.

Verification:
- worker tests: 41 passed
- worker typecheck
- site tests: 26 passed
- site production build
- git diff --check
2026-07-22 21:24:27 +08:00
SivanCola d97dfdcfbf feat(registry): support plugin submissions / 支持插件类型提交 (#6816)
* feat(registry): support plugin submissions

Problem:
The community registry exposed only skill and MCP package kinds even though the installer already supports plugin packages.

Root cause:
Plugin was absent from the site form, query and publish schemas, shared package types, and republish persistence.

Fix:
Add plugin as a first-class registry kind, require installable GitHub plugin sources, persist kind changes on republish, and expose plugin filtering and guidance in the site and moderation UI.

Verification:
- npm test (workers/crash-report)
- npm run typecheck (workers/crash-report)
- npm test (site)
- npm run build (site)
- Browser verification at /skills/

* fix(registry): re-review package reclassifications

Problem:
Package owners could reclassify an active or verified package as a plugin without returning it to moderation, and the publish API accepted plugin installers hidden behind skill or MCP kinds.

Root cause:
Republish persisted kind without coupling the change to status and verification, while validation only checked installer mismatches when kind itself was plugin.

Fix:
Return publisher-initiated active kind changes to pending, clear verification on every non-admin kind change, and require explicit installer kinds to match the public package kind.

Verification:
- npm test (workers/crash-report): 29 passed
- npm run typecheck (workers/crash-report)
- git diff --check

* fix(registry): disclose compatible plugin formats

Problem:
The registry publish form and API error claimed plugin submissions required a Reasonix-native manifest, even though the installer also accepts Codex and Claude plugin packages.

Root cause:
The submission guidance was written for the first native manifest path and was not updated when compatibility parsers and Claude marketplace support were added.

Fix:
List the native, Codex, Claude, and supported Claude marketplace manifests in both the form guidance and server validation error, with contract tests covering every format.

Verification:
- worker test suite: 30 tests passed
- worker TypeScript typecheck passed
- site test suite: 24 tests passed
- Astro production build passed
- git diff --check passed

* fix(registry): pin installs to declared package kind

Problem:
- Registry skill and MCP submissions could retain installKind=auto, while copied install requests omitted the declared kind.

Root cause:
- Only plugin submissions normalized auto, and the API echoed legacy installer values independently of the public package kind.

Fix:
- Normalize every submission and API response to the declared kind, and preserve that kind in publish and copy flows.

Verification:
- workers/crash-report: npm test; npm run typecheck
- site: npm test; npm run build
- git diff --check

* fix(registry): harden plugin source validation

Problem:
Plugin submissions could preserve control characters in copied install prompts, and non-canonical GitHub URLs could select paths outside the cloned repository.

Root cause:
The registry accepted any GitHub URL with two path segments while the Go installer ignored unsupported URL tails and joined tree paths without a containment check.

Fix:
Reject unsafe source characters and non-repository GitHub shapes, make the shared Go parser fail closed, and verify selected plugin roots remain inside the resolved clone.

Verification:
go test ./internal/installsource
env -u DEEPSEEK_API_KEY go test ./...
go vet ./...
npm test && npm run typecheck (workers/crash-report)
npm test && npm run build (site)
2026-07-22 19:20:40 +08:00
SivanColaandHaoyueQin 5888d5df75 Fix theme editor save actions and responsive layout / 修复主题编辑器保存操作与响应式布局 (#6788) (#6821)
* fix(desktop): keep theme editor actions accessible

Problem:
- The theme editor could hide its Save and Apply actions inside the settings layout on Windows and compress controls in Creation mode.
- The default code-viewer contract could race React 19 lazy resolution.

Root cause:
- The fixed editor overlay remained inside a containing block and responded to the global viewport instead of its own available width.
- The code-viewer assertion assumed one task flush was enough for Suspense to resolve.

Fix:
- Portal the editor to the document body, retain accessible modal focus behavior, and use editor-scoped container queries with stable action placement.
- Let segmented controls shrink and wrap at narrow editor widths.
- Wait for the lazy default code viewer before asserting its DOM contract.

Verification:
- pnpm test
- pnpm build
- Browser validation at the reported Windows flow and a 957x675 viewport

* fix(desktop): contain theme editor escape handling

Problem:
Saving a theme left Escape unowned by the nested editor, allowing the outer Settings modal to close. Safe-area segmented controls also used a narrower selector than the related contribution in #6726.

Root cause:
The busy branch returned before preventing propagation, and the document listener captured stale busy and cancel closures. The segmented button rule was scoped only to direct .set-seg children.

Fix:
Always consume Escape while the editor is mounted, read live busy and cancel values through refs, and add a runtime modal-ownership regression. Adopt the unified setting-row selector from #6726.

Verification:
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- pnpm test
- pnpm build
- git diff --check

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

---------

Co-authored-by: HaoyueQin <[email protected]>
2026-07-22 19:11:56 +08:00
SivanCola 9fafac05f4 fix(provider): classify MiniMax content-review errors (#6824)
Problem:
MiniMax content-review failures 1026 and 1027 arrive as HTTP 422, so Reasonix mislabeled them as invalid parameters and discarded the provider trace identifier.

Root cause:
Provider errors were classified only by HTTP status, and APIError retained the response body but not trace_id response headers.

Fix:
Recognize MiniMax input/output content-review responses, show localized recovery guidance without blindly retrying 422 requests, parse MiniMax base_resp messages, and preserve bounded trace IDs for diagnostics.

Verification:
- go test ./internal/provider ./internal/control ./internal/i18n
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check
2026-07-22 18:45:21 +08:00
ecd66a30f9 feat: show selected text card in chat bubble with formatted preview选中引用文本卡片显示 (#6813)
* feat: show selected text card in chat bubble with formatted preview

Display the selected chat/code text as an expandable card in the sent
message, reusing the existing paste-block mechanism. The input-area
tooltip now renders with Markdown for syntax-highlighted preview.

Changes:
- selectedTextContext.ts: add SELECTION_LABEL_RE and formatSelectionLabel
- Composer.tsx: add selection labels to displayRefs, begin/end markers,
  and Markdown tooltip for input cards
- ComposerContextCard.tsx: tooltipLabel type string -> ReactNode
- Message.tsx: extend PASTE_LABEL_RE to match selection labels, use
  Markdown for expanded paste/selection content
- styles.css: .msg-pasted-expanded .md white-space, .tooltip .md pre
- test: update display assertion for new label format

* fix(desktop): keep selected context cards display-only

Problem:
Selected-text cards duplicated quoted content into provider-visible submit bytes, used truncated labels as unique identities, and rendered existing pasted blocks as Markdown.

Root cause:
The card UI reused pasted-text begin/end markers stored inside submitText instead of reading the selected-context payload that was already persisted for the turn.

Fix:
Recover selection cards from the existing safe JSON payload, correlate duplicate labels by occurrence, sanitize file-name brackets, render code with the highlighted code viewer, and keep pasted blocks literal.

Verification:
- pnpm test
- pnpm build
- pnpm typecheck
- pnpm test:typecheck
- ./scripts/cache-guard.sh
- Browser-verified duplicate chat cards, bracketed file names, code indentation/highlighting, Markdown chat content, and literal pasted text

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

* fix(desktop): preserve selected context through edits

Problem:
Editing a message with selected-context cards removed the quoted context while leaving inert labels, and label-like prose could be consumed as a card.

Root cause:
Display labels were parsed with a global regex and edit replay treated the selected-context suffix as part of the visible body.

Fix:
Derive exact label suffixes from validated JSON metadata, isolate generated labels from editable text, and split/reappend the selected-context block during replay.

Verification:
- targeted selected-context and edit-replay tests
- pnpm build
- pnpm test:typecheck
- ./scripts/cache-guard.sh
- browser-verified selected-context edit preservation

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

---------

Co-authored-by: SauronSkywalker <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-22 18:39:39 +08:00
SivanCola d6a5548398 Refine Auto plan decision boundaries / 精简 Auto 计划决策边界 (#6817)
* feat(auto): reserve prompts for plan decisions

Problem:
Auto Guard interrupted ordinary execution based on inferred risk, while material plan changes did not present a focused, neutral decision surface.

Root cause:
Recovery policy mixed execution-risk classification with user-owned strategy and scope choices, and recovery events did not carry enough plan context for clients to show the transition.

Fix:
Keep ordinary Auto execution under existing permission and sandbox policy, review structural plan transitions independently, stop repeated technical failures without asking for risk consent, and present optional before/after plan deltas across Desktop, TUI, and bot clients.

Verification:
- go test ./internal/eventwire ./internal/recovery ./internal/agent ./internal/control ./internal/cli ./internal/bot -count=1
- pnpm typecheck
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/decision-surface.test.tsx
- pnpm check:css
- go run ./cmd/remote-protocol-gen -root . -check
- ./scripts/cache-guard.sh

* Fix Auto plan decision follow-through

Problem:

Adopting an Auto plan transition could still fail todo continuity, Plan plus Auto could ask twice for the same plan, and the CLI preselected the adopt choice.

Root cause:

The recovery decision carried no one-call replacement authorization, plan-transition review ignored active Plan mode, and approval selection always started at the first row.

Fix:

Carry a host-only replacement authorization into the approved todo_write call, preserve completed task history, use the existing Plan exit gate during planning, and require an explicit CLI plan choice.

Verification:

Focused agent, recovery, todo, evidence, and CLI tests passed. go vet ./... passed. The cache guard and cache-impact check passed; the optional live provider probe passed with the invalid machine credential unset.
2026-07-22 18:19:03 +08:00
ffff2004andSivanCola 5bea38e530 fix(config): 修复permissions配置更新时的数据丢失问题 (#6784)
* fix(config): preserve permission policy when persisting approvals

Replace the allow-only section rewrite with a locked, strict, targeted mutation so concurrent approvals cannot drop existing policy fields or comments.\n\nRefs #2

* fix(config): respect TOML multiline strings in permission updates

Problem: Permission persistence could mistake section examples inside multiline strings for real config and report success after editing prompt text.

Root cause: The line-based section scanner did not preserve multiline basic or literal string state across lines, and candidate output was written without semantic validation.

Fix: Track TOML string forms while locating structural section and key lines, then require the candidate to parse and contain the exact requested allow rules before atomic replacement.

Verification: go test ./internal/config ./internal/boot ./internal/control; focused race tests for permission persistence; env -u DEEPSEEK_API_KEY go test ./...; go vet ./...; git diff --check.

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-22 17:20:02 +08:00
SivanCola d61b087dd9 Fix Windows portable entrypoint collision / 修复 Windows 便携包入口冲突 (#6806)
* fix(desktop): prevent Windows portable entrypoint collision

Problem:
Windows portable staging used Reasonix.exe for the GUI launcher and reasonix.exe for the bundled CLI. On case-insensitive Windows filesystems, the CLI silently overwrote the launcher, so desktop shortcuts opened the CLI.

Root cause:
The packaging contract was checked only as source strings and never validated against the final Windows staging directory.

Fix:
Give the internal Windows CLI sidecar the distinct reasonix-cli.exe name, update installer, updater, and remote lookup contracts, and verify the exact portable file set plus launcher identity before archiving.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- cd desktop && go vet ./... && go build ./...
- go vet ./... && go build ./...
- Windows PE cross-build and portable verifier simulation

* fix(repair): accept renamed Windows CLI sidecar

Problem:
Windows desktop updates record reasonix-cli.exe in the pending release unit, but the repair validator rejects that renamed sidecar when the transaction is read back.

Root cause:
The packaging and updater contracts were updated to the collision-free filename without extending the repair transaction allowlist.

Fix:
Allow reasonix-cli.exe only as a non-primary release sibling and cover the complete Windows release unit through a PrepareFileUpdate-to-ReadPendingUpdate round trip.

Verification:
- go test ./internal/repair
- go test -race ./internal/repair
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- git diff --check
2026-07-22 15:58:16 +08:00
par73e eac67a7fbf feat(desktop): enhance workspace file tree (#6807) 2026-07-22 15:49:11 +08:00
14e97c92a2 fix(acp): order command updates after session responses (#6803)
Problem: ACP clients can discard available_commands_update when it arrives before a session-opening response, leaving slash commands unavailable after new, load, or resume.

Root cause: Session handlers emitted updates while their JSON-RPC request was still executing. Moving the send to a goroutine did not establish a wire-order guarantee.

Fix: Add a transport-owned post-response hook and route command advertisements for session/new, session/load, and session/resume through it while preserving transcript replay and config-rebuild ordering.

Verification: Focused wire-order tests repeated 10 times, ACP/control tests, ACP race tests, go vet, and the full Go suite passed.

Co-authored-by: RainbowXie <[email protected]>
Co-authored-by: ssfdust <[email protected]>
2026-07-22 14:38:22 +08:00
699 changed files with 99187 additions and 11240 deletions

No files matched your search

+12
View File
@@ -1,3 +1,15 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
# Keep the provider-side signing policy and its GitHub control plane under the
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
+12
View File
@@ -4,6 +4,18 @@ on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
# The guard only cares about the prompt/tool surfaces listed in
# scripts/check-cache-impact.sh; PRs confined to clearly unrelated paths
# can never trip it, so skip the run entirely. Keep the ignore list
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
permissions:
contents: read
+149 -9
View File
@@ -14,22 +14,78 @@ concurrency:
cancel-in-progress: true
jobs:
# Cheap path gate for pull requests. PRs confined to docs/site/release-notes
# (or top-level Markdown) skip the heavy Go jobs, and PRs that cannot affect
# the desktop module skip the desktop jobs. Job-level `if` reports skipped,
# which satisfies the required status checks (lint, race, test) — a
# workflow-level paths-ignore would leave required checks pending and block
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
# `always()`, a failed `changes` job (or a missing output) makes them run
# the full matrix instead of silently passing required checks as skipped.
# Pushes to main-v2 always run everything.
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
desktop: ${{ steps.filter.outputs.desktop }}
site: ${{ steps.filter.outputs.site }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; site=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
elif [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
base="${{ github.event.before }}"
fi
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; site=false
# Root-module CI: desktop/ is a separate module, so only the
# clearly unrelated paths below can skip it.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
# desktop/ imports the root kernel via `replace reasonix => ../`,
# so only this clearly-unrelated set is safe to skip.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
if echo "$files" | grep -q '^site/'; then site=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; } >> "$GITHUB_OUTPUT"
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
# those required checks would stay "Expected" and block merging. The job
# therefore always runs and the steps do the gating: when the changes
# detector reports the diff is unrelated, every step skips and each leg
# reports success in seconds. `always()` also keeps the legs alive when
# the changes job itself fails (fail-open: an empty output != 'false').
test:
needs: changes
if: always()
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
steps:
- uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
- uses: actions/setup-go@v6
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
if: runner.os == 'Linux'
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
@@ -45,7 +101,7 @@ jobs:
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
# file. gofmt output is OS-independent, so the Unix legs already cover it.
- name: gofmt
if: runner.os != 'Windows'
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
run: |
# Root module only — desktop/ is a separate module with its own tooling.
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
@@ -56,13 +112,15 @@ jobs:
fi
- name: vet
if: env.RUN_STEPS == 'true'
run: go vet ./...
- name: build
if: env.RUN_STEPS == 'true'
run: go build ./...
- name: test
if: runner.os != 'Windows'
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -70,8 +128,19 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, plus cmd/. The full ./... sweep stays on
# pushes to main-v2; the Unix legs always run the full suite.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 10
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
@@ -84,6 +153,8 @@ jobs:
run: go test -p 4 -timeout=3m ./...
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -108,13 +179,24 @@ jobs:
# The matrix never runs -race (it needs cgo); the project's concurrency
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
# ship without race coverage.
- name: test -race
# ship without race coverage. Pull requests sweep only the
# concurrency-heavy packages so this required check stays fast; pushes
# to main-v2 keep the full ./... sweep as the safety net.
- name: test -race (concurrency packages)
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/...
- name: test -race (full)
if: github.event_name != 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
desktop:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: ubuntu-22.04
defaults:
run:
@@ -174,6 +256,9 @@ jobs:
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: Test integrated terminal frontend
run: pnpm --dir frontend test:terminal
- name: vet
run: go vet ./...
@@ -190,11 +275,34 @@ jobs:
- name: test
run: go test ./...
# desktop/ is a separate module, so the root macOS matrix above does not
# compile or exercise the native PTY implementation.
desktop-macos:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: macos-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- name: Test integrated terminal and PTY lifecycle
run: go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
# run on the ubuntu leg above.
desktop-windows:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: windows-latest
defaults:
run:
@@ -237,14 +345,21 @@ jobs:
timeout-minutes: 15
run: go test ./...
# Installer packaging is packaging validation, not a code gate: run it
# on pushes to main-v2 (the release pipeline builds installers again
# anyway), but let pull requests stop after the test step.
- name: Install NSIS
if: github.event_name != 'pull_request'
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
- name: Build Windows installer and portable archive
if: github.event_name != 'pull_request'
timeout-minutes: 20
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
lint:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -260,11 +375,30 @@ jobs:
version: v2.12.2
args: --timeout=5m
# Report-only complexity monitor for internal/agent. Does not duplicate the
# main lint/test/race gates; funlen+cyclop run with issues-exit-code=0 so
# the baseline is visible as a CI summary/artifact without blocking merges.
- name: agent complexity report
if: always()
run: |
# Reuse the golangci-lint binary installed by the previous step.
bash scripts/agent-complexity-report.sh
- name: upload agent complexity report
if: always()
uses: actions/upload-artifact@v7
with:
name: agent-complexity-report
path: agent-complexity-report.txt
if-no-files-found: ignore
retention-days: 14
- name: release workflow contracts
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/release-cli-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
@@ -275,6 +409,8 @@ jobs:
# 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.
site:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.site != 'false')
runs-on: ubuntu-latest
defaults:
run:
@@ -290,6 +426,8 @@ jobs:
run: npm test
govulncheck:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
@@ -307,6 +445,8 @@ jobs:
run: govulncheck ./...
coverage:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+10
View File
@@ -1,10 +1,20 @@
name: CodeQL
on:
# Docs/release-notes/root-Markdown changes carry no analyzable code; the
# weekly cron keeps the full scan cadence regardless.
push:
branches: ["main-v2"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
pull_request:
branches: ["main-v2"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
schedule:
- cron: "27 3 * * 1"
+57 -8
View File
@@ -11,6 +11,10 @@ on:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
target_pr:
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
required: false
type: string
permissions:
contents: write
@@ -29,20 +33,54 @@ jobs:
with:
node-version: "22"
- name: Prepare the release-notes branch
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
VERSION: ${{ inputs.version }}
TARGET_PR: ${{ inputs.target_pr }}
run: |
set -euo pipefail
branch="release-notes/v${VERSION#v}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" main-v2
git fetch origin main-v2
if [ -n "$TARGET_PR" ]; then
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::target_pr must be a positive pull request number"
exit 1
fi
mapfile -t pr_state < <(
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
--json state,baseRefName,headRefName,isCrossRepository \
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
)
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
echo "::error::target_pr must be an open pull request into main-v2"
exit 1
fi
if [ "${pr_state[3]}" != "false" ]; then
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
exit 1
fi
branch="${pr_state[2]}"
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
exit 1
fi
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
else
git checkout -b "$branch"
branch="release-notes/v${VERSION#v}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_PR=" >> "$GITHUB_ENV"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
@@ -61,6 +99,11 @@ jobs:
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:
@@ -71,10 +114,16 @@ jobs:
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Generate a bilingual, product-focused draft from merged pull request metadata. The reviewed catalog will drive the website, desktop app, and GitHub releases."
-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 [ -n "$RELEASE_NOTES_PR" ]; then
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
+44
View File
@@ -0,0 +1,44 @@
name: Relay CLI prerelease
run-name: Relay CLI prerelease ${{ github.ref_name }}
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
# carries only the immutable candidate tag and its classified channel into the
# protected release control plane.
on:
push:
tags: ["v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected CLI control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
const tag = process.env.RELEASE_TAG;
const channel = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag)
? 'preview'
: 'stable';
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel,
tag,
},
});
@@ -0,0 +1,43 @@
name: Relay desktop prerelease
run-name: Relay desktop prerelease ${{ github.ref_name }}
# Keep the production signing build on protected main-v2. The tag-triggered
# relay carries only the immutable candidate tag into the protected workflow.
on:
push:
tags: ["desktop-v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected desktop control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-desktop.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-desktop.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel: 'stable',
tag: process.env.RELEASE_TAG,
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,42 @@
name: Relay stable release
run-name: Relay stable release ${{ github.ref_name }}
# The tag event itself has a tag-shaped origin. Relay the immutable tag name to
# release-stable.yml on protected main-v2 so the production SignPath policy can
# allow exactly one branch name without trusting wildcard tag-like branches.
on:
push:
tags:
- 'v*'
- '!v*-*'
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected stable control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-stable.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-stable.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
tag: process.env.RELEASE_TAG,
allow_recovery: 'false',
},
});
+75 -18
View File
@@ -2,24 +2,42 @@ name: Release stable
run-name: Release stable ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow,
# preflight verifies that all three tags point to the current main-v2 commit,
# and one GitHub approval releases every channel. A manual recovery runs the
# fixed control plane from protected main-v2 while preserving an older tagged
# candidate on main-v2 history. SignPath intentionally keeps its separate
# manual confirmations. Prerelease and canary workflows remain independently
# callable.
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
# tag to this workflow on protected main-v2. Preflight verifies that all three
# tags point to the current main-v2 commit, and one GitHub approval releases
# every channel. After approval, a zero-publication Windows preflight verifies
# both architectures and signing stages before any public channel starts.
# Keeping the control-plane ref on main-v2 lets SignPath restrict
# production signing to that one protected origin instead of trusting wildcard
# tag-like branch names. Manual recovery uses the same fixed control plane while
# preserving an older tagged candidate on main-v2 history.
on:
push:
tags:
- 'v*'
- '!v*-*'
workflow_dispatch:
inputs:
tag:
description: "Existing stable CLI tag to recover (for example v1.18.0)"
required: true
type: string
publish_cli:
description: "Recover the CLI/Homebrew channel"
required: false
default: true
type: boolean
publish_npm:
description: "Recover the npm channel"
required: false
default: true
type: boolean
publish_desktop:
description: "Recover the Desktop/R2 channel"
required: false
default: true
type: boolean
allow_recovery:
description: "Allow an existing stable tag on main-v2 history (manual recovery only)"
required: false
default: true
type: boolean
concurrency:
group: stable-release-${{ inputs.tag || github.ref_name }}
@@ -61,10 +79,20 @@ jobs:
id: release
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- name: Validate reviewed release notes
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
# which can predate its reviewed release-note entry. Carry the exact file
# validated by this protected control-plane job into both publishers.
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: stable-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
- name: Cache hit guard
run: ./scripts/cache-guard.sh
@@ -102,7 +130,8 @@ jobs:
cli:
name: publish CLI and Homebrew
needs: authorize
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_cli) }}
uses: ./.github/workflows/release.yml
with:
tag: ${{ needs.authorize.outputs.cli_tag }}
@@ -113,7 +142,8 @@ jobs:
npm:
name: publish npm
needs: authorize
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_npm) }}
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
@@ -124,9 +154,10 @@ jobs:
orchestrated: true
secrets: inherit
desktop:
name: publish desktop
signpath-preflight:
name: verify stable SignPath control plane
needs: authorize
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
@@ -134,6 +165,21 @@ jobs:
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
signing_preflight: true
secrets: inherit
desktop:
name: publish desktop
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish_desktop) }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: ${{ needs.authorize.outputs.desktop_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
signing_preflight_verified: true
secrets: inherit
postflight:
@@ -146,14 +192,25 @@ jobs:
steps:
- name: Require every publisher to succeed
env:
PUBLISH_CLI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_cli }}
PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
PUBLISH_DESKTOP: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do
for channel in cli npm desktop; do
selected_var="PUBLISH_${channel^^}"
result_var="${channel^^}_RESULT"
selected="${!selected_var}"
result="${!result_var}"
if [ "$selected" != "true" ]; then
echo "$channel recovery skipped; public postflight will still verify it"
continue
fi
if [ "$result" != "success" ]; then
echo "::error::stable publisher result is $result, expected success"
echo "::error::$channel stable publisher result is $result, expected success"
exit 1
fi
done
+254 -26
View File
@@ -1,21 +1,33 @@
name: Release
# CLI binary line. Stable releases are called by release-stable.yml after its
# single GitHub `release`-environment approval. Direct tag pushes are retained
# only for CLI prereleases so they can still ship independently; those runs keep
# their own approval gate. goreleaser auto-skips the cask upload for prereleases
# (brew has no prerelease channel — see .goreleaser.yaml).
# Native CLI binary line. Stable releases are called by release-stable.yml after
# its single GitHub `release`-environment approval. Preview uses immutable
# vX.Y.Z-preview.N tags, publishes a GitHub prerelease behind the same `canary`
# gate as Desktop Preview, and never moves Homebrew or GitHub Latest. Other
# standalone prereleases remain internal stable-channel checkpoints behind the
# `release` gate.
on:
push:
tags: ['v*-*']
workflow_dispatch:
inputs:
channel:
description: "Native CLI release channel"
required: true
# Preserve existing tag-only recovery dispatches as Stable. Preview must
# be an explicit operator choice or arrive through the tag relay.
default: stable
type: choice
options: [stable, preview]
tag:
description: "Existing CLI tag to publish (for example v1.18.0-rc.1)"
description: "Existing protected CLI tag (Preview: v1.18.0-preview.1; Stable/RC: v1.18.0-rc.1)"
required: true
type: string
workflow_call:
inputs:
channel:
description: "Native CLI release channel selected by the approved orchestrator"
required: false
default: stable
type: string
tag:
description: "Existing CLI tag selected by the approved release orchestrator"
required: true
@@ -37,9 +49,69 @@ on:
permissions:
contents: write # create the release and upload archives
concurrency:
# A channel pointer is a monotonic public state machine. Serialize all
# publishers for the same channel so an older recovery run cannot pass its
# read-before-write window after a newer release has published.
group: release-cli-${{ inputs.channel || 'stable' }}
cancel-in-progress: false
jobs:
resolve:
name: resolve CLI release
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
notes_version: ${{ steps.release.outputs.notes_version }}
channel: ${{ steps.release.outputs.channel }}
prerelease: ${{ steps.release.outputs.prerelease }}
sha: ${{ steps.candidate.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Resolve channel and tag
id: release
env:
EVENT_NAME: ${{ github.event_name }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
run: bash scripts/resolve-cli-release.sh
- name: Record immutable candidate
id: candidate
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_CHANNEL: ${{ steps.release.outputs.channel }}
run: |
set -euo pipefail
git fetch origin main-v2
sha="$(git rev-parse "$RELEASE_TAG^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/main-v2; then
echo "::error::$RELEASE_TAG points to $sha, which is not on main-v2 history"
exit 1
fi
if [ "$RELEASE_CHANNEL" = "preview" ] && [ "$sha" != "$(git rev-parse origin/main-v2)" ]; then
echo "::error::CLI Preview must tag current main-v2; $RELEASE_TAG points to $sha"
exit 1
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Verify existing protected tag
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
APPROVED_SHA: ${{ steps.candidate.outputs.sha }}
VERIFY_RELEASE_CHECKOUT: false
run: bash scripts/verify-release-tag.sh
orchestration-guard:
name: verify approved orchestrator
needs: resolve
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
@@ -68,23 +140,25 @@ jobs:
release-gate:
name: approve standalone CLI release
needs: resolve
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
environment: ${{ needs.resolve.outputs.channel == 'preview' && 'canary' || 'release' }}
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone CLI release $RELEASE_TAG"
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
run: echo "Approved standalone CLI $RELEASE_CHANNEL release $RELEASE_TAG"
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
needs: [resolve, orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -93,7 +167,7 @@ jobs:
goreleaser:
name: archives + checksums + homebrew tap
needs: cache-guard
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
@@ -103,7 +177,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -111,15 +185,26 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Download orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
uses: actions/download-artifact@v8
with:
name: stable-reviewed-release-notes
path: /tmp/stable-reviewed-release-notes
- name: Use orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
run: |
test -s /tmp/stable-reviewed-release-notes/release-notes.md
cp /tmp/stable-reviewed-release-notes/release-notes.md /tmp/release-notes.md
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
run: bash scripts/verify-release-tag.sh
- uses: goreleaser/goreleaser-action@v7
with:
@@ -127,22 +212,165 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && 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.
GORELEASER_CURRENT_TAG: ${{ inputs.tag || github.ref_name }}
GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }}
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
run: gh release edit "$TAG" --notes-file /tmp/release-notes.md
- name: Publish CLI release metadata to R2
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 != '' }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping CLI release metadata"
exit 0
fi
channel=""
if [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
channel="stable"
elif [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$ ]]; then
channel="preview"
else
echo "internal CLI release $TAG; publishing only immutable metadata"
fi
required_assets='[
"reasonix-darwin-amd64.tar.gz",
"reasonix-darwin-arm64.tar.gz",
"reasonix-linux-amd64.tar.gz",
"reasonix-linux-arm64.tar.gz",
"reasonix-windows-amd64.zip",
"reasonix-windows-arm64.zip",
"SHA256SUMS"
]'
gh api "repos/${{ github.repository }}/releases/tags/$TAG" > /tmp/cli-release.raw.json
jq --arg tag "$TAG" --argjson required "$required_assets" '
if .tag_name != $tag then error("release tag mismatch") else . end |
if .draft then error("draft release cannot be published") else . end |
. as $release |
($release.assets | map({key: .name, value: .}) | from_entries) as $assets |
if ($required | all(. as $name | $assets[$name] != null))
then {
tag_name: $release.tag_name,
prerelease: $release.prerelease,
html_url: $release.html_url,
assets: [
$required[] as $name |
$assets[$name] |
{
name: .name,
browser_download_url: .browser_download_url,
size: .size
}
]
}
else error("release is missing one or more required CLI assets")
end
' /tmp/cli-release.raw.json > /tmp/cli-release.json
if [ -n "$channel" ]; then
bash scripts/validate-cli-release-manifest.sh \
"$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.json
else
bash scripts/validate-cli-release-manifest.sh \
any "$TAG" "${{ github.repository }}" /tmp/cli-release.json
fi
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
validation_channel="${channel:-any}"
immutable_key="cli/releases/${TAG}/latest.json"
immutable_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
--endpoint-url "$endpoint" 2>"$immutable_error"; then
bash scripts/validate-cli-release-manifest.sh \
"$validation_channel" "$TAG" "${{ github.repository }}" \
/tmp/cli-release.immutable.json
if ! cmp -s /tmp/cli-release.json /tmp/cli-release.immutable.json; then
echo "::error::immutable CLI release metadata for $TAG already exists with different content"
exit 1
fi
echo "immutable CLI release metadata for $TAG already exists; preserving it"
elif grep -Eiq '404|NoSuchKey|Not Found' "$immutable_error"; then
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/${immutable_key}" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable"
else
cat "$immutable_error" >&2
exit 1
fi
rm -f "$immutable_error"
aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
--endpoint-url "$endpoint"
bash scripts/validate-cli-release-manifest.sh \
"$validation_channel" "$TAG" "${{ github.repository }}" \
/tmp/cli-release.immutable.json
cmp -s /tmp/cli-release.json /tmp/cli-release.immutable.json
if [ -z "$channel" ]; then
echo "internal CLI release $TAG; Stable and Preview pointers remain unchanged"
exit 0
fi
current_tag=""
pointer_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
--endpoint-url "$endpoint" 2>"$pointer_error"; then
current_tag="$(jq -er '.tag_name | strings' /tmp/cli-release.pointer.json)"
bash scripts/validate-cli-release-manifest.sh \
"$channel" "$current_tag" "${{ github.repository }}" /tmp/cli-release.pointer.json
elif grep -Eiq '404|NoSuchKey|Not Found' "$pointer_error"; then
echo "CLI $channel pointer does not exist yet"
else
cat "$pointer_error" >&2
exit 1
fi
rm -f "$pointer_error"
pointer_manifest=-
if [ -n "$current_tag" ]; then
pointer_manifest=/tmp/cli-release.pointer.json
fi
pointer_decision="$(
bash scripts/decide-cli-pointer-update.sh \
"$channel" /tmp/cli-release.json "$pointer_manifest"
)"
if [ "$pointer_decision" = "skip" ]; then
echo "CLI $channel pointer remains ${current_tag:-unset}; candidate $TAG is not newer and needs no repair"
exit 0
fi
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
--endpoint-url "$endpoint"
bash scripts/validate-cli-release-manifest.sh \
"$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.pointer.json
cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json
echo "CLI $channel pointer -> $TAG"
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
@@ -184,7 +412,7 @@ jobs:
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ inputs.tag || github.ref_name }}
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: |
set -euo pipefail
@@ -231,7 +459,7 @@ jobs:
# owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
case "$TAG" in
+1
View File
@@ -71,3 +71,4 @@ temp/
# Official theme art intermediate outputs / caches
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
agent-complexity-report.txt
+40
View File
@@ -0,0 +1,40 @@
# Report-only complexity monitor for internal/agent production code.
# Uses funlen + cyclop with generous thresholds so existing code does not
# generate a flood of noise. issues-exit-code is forced to 0 by the CI step so
# this never blocks merges while we establish a baseline and track trend.
version: "2"
run:
tests: false
linters:
default: none
enable:
- funlen
- cyclop
settings:
funlen:
# Deliberately above a hard 50-line rule: length alone is not the
# acceptance gate. This surfaces only the remaining large hotspots after
# the Run/executeOne extraction so trend can be tracked.
lines: 120
statements: 80
ignore-comments: true
cyclop:
max-complexity: 30
package-average: 0
skip-tests: true
issues:
max-issues-per-linter: 0
max-same-issues: 0
# Report mode: never fail the job. Baseline/trend is the gate, not a hard cut.
# The CI step also passes --issues-exit-code=0 for older golangci-lint builds.
uniq-by-line: false
output:
formats:
text:
path: stdout
print-linter-name: true
print-issued-lines: true
+3 -2
View File
@@ -34,8 +34,9 @@ checksum:
homebrew_casks:
- name: reasonix
ids: [reasonix]
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
# Prereleases (including v*-preview.* and v*-rc*) must not update the stable
# tap; brew has no separate prerelease channel, so uploading either cask
# would make it the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Canary-only configuration. SignPath test certificates intentionally do not
chain to a Windows trusted root, so authenticode-verify cannot be used for
the already test-signed payload. The workflow verifies that every payload
executable is signed and hash-identical to the rebuilt portable package.
Stable and RC releases use windows-installer-v2 instead. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="*installer*.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Sign the finished NSIS container only when the exact payload used to build it
is present and already Authenticode-signed. GitHub Actions wraps this flat
bundle in a zip before SignPath receives it. Keep this versioned separately
so provisioning it cannot break releases still using windows-installer. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="*installer*.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-desktop.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-guard.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-launcher.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-update-helper.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-cli.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-uninstall.exe">
<authenticode-verify />
</pe-file>
</zip-file>
</artifact-configuration>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Sign every executable that the NSIS installer writes to disk. GitHub Actions
wraps the flat payload directory in this zip before SignPath receives it. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="reasonix-desktop.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-guard.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-launcher.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-update-helper.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-cli.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-uninstall.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
+20
View File
@@ -0,0 +1,20 @@
version: 1
project_slug: DeepSeek-Reasonix
signing_policy_slug: release-signing
repository_url: https://github.com/esengine/DeepSeek-Reasonix.git
allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/release-stable.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-desktop.yml
- .github/workflows/release-desktop-trigger.yml
- .signpath/artifact-configurations/windows-payload.xml
- .signpath/artifact-configurations/windows-installer-v2.xml
- scripts/complete-signpath-request.ps1
- scripts/package-windows-desktop.sh
- scripts/verify-windows-authenticode.ps1
- cmd/signpath-contract/main.go
+22
View File
@@ -69,6 +69,28 @@ branch.
### Fixed
- Hardened Bash permission reuse for dynamic and indirect execution. Parameter/arithmetic expansions,
assignments, redirects, heredocs, and globs can only be remembered as exact
`Bash=<literal>` rules, while still using Auto's normal fallback. Nested or
indirect execution now requires a human in interactive Ask/Auto and fails
closed in headless Ask/Auto/DontAsk. Broad Bash rules, Guardian/hook allows,
and the approved-plan window can no longer silently authorize that stricter
class; YOLO remains the explicit full-access bypass and sandbox enforcement
is unchanged.
- Fixed Desktop sessions incorrectly locking themselves during Goal + Delivery
mode changes, controller rebuilds, duplicate-tab restore, and background
reattachment. Desktop now keeps one process-local runtime owner per canonical
session, fences stale controller events by runtime epoch, blocks sends until
that runtime is ready, and scopes single-instance ownership to
`REASONIX_HOME` instead of the executable path. Switching saved sessions is
now transactional: a target build, restore, or lease failure leaves the
current controller, lease, path, mode profile, and runtime epoch untouched.
- Stabilized the desktop rich composer caret after skill and plugin invocation
tags. DOM→model and model→DOM selection mapping now treat invocation chips as
zero-length atoms while still counting user text that lands inside the NBSP
caret anchor (common on Windows WebView2), restore both selection ends, and
recover the insertion point from a `beforeinput` snapshot when the browser
temporarily loses selection — so mid-text edits no longer jump to the end.
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
exited proxy client cannot leave the embedded UI hidden during startup. If
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
+1
View File
@@ -139,6 +139,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
+1
View File
@@ -131,6 +131,7 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.zh-CN.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
+12 -6
View File
@@ -17,13 +17,19 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
## Memory
- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md`
(personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any
`REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name.
- Standing instructions are hierarchical: committed/shared `REASONIX.md`,
`AGENTS.md`, and `CLAUDE.md`; personal `*.local.md` variants; matching files in
ancestor directories; and user-global files under the memory state root
(`REASONIX_STATE_HOME`, otherwise `REASONIX_HOME`, otherwise `~/.reasonix` on
macOS/Linux or `%APPDATA%\reasonix` on Windows). All distinct supported files
in a directory load; `AGENTS.md` is not merely a fallback.
- `@path` on its own line imports another file's contents.
- `#<note>` in chat quick-adds a line here. The `remember` tool saves durable
facts to the per-project auto-memory store (frontmatter files + `MEMORY.md`
index), which loads into the prefix on the next session.
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
index). Fact `type` classifies content; independent `scope` controls whether it
is project-only (the default) or explicitly global. The index loads into the
stable prefix on the next session; global user/feedback bodies also load as
lower-priority compatibility guidance. The current turn receives a tail note.
## Notes
+34 -12
View File
@@ -11,6 +11,7 @@ import (
"time"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
@@ -49,9 +50,11 @@ func runAssist(args []string) int {
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
Preview []repair.RepairPlanPreview `json:"preview"`
}{plan, preview}); code != 0 {
Plan repair.RepairPlan `json:"plan"`
PlanID string `json:"planId"`
Preview []repair.RepairPlanPreview `json:"preview"`
PreviewID string `json:"previewId"`
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
return code
}
} else {
@@ -64,6 +67,7 @@ func runAssist(args []string) int {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
@@ -84,6 +88,7 @@ func runApplyPlan(args []string) int {
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
@@ -107,10 +112,21 @@ func runApplyPlan(args []string) int {
if !*jsonOut {
printPlanPreview(plan, preview)
}
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
expectedPreviewID := strings.TrimSpace(*previewID)
if *yes && expectedPreviewID == "" {
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
return 2
}
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
return 1
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = actualPreviewID
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
@@ -140,15 +156,7 @@ func requestRepairPlan(ctx context.Context, root, modelRef string, report repair
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": entry.Effort, "reasoning_protocol": entry.ReasoningProtocol,
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": cfg.NetworkProxySpec(),
},
})
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
if err != nil {
return repair.RepairPlan{}, err
}
@@ -186,6 +194,20 @@ func requestRepairPlan(ctx context.Context, root, modelRef string, report repair
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
},
}
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
+102
View File
@@ -2,12 +2,39 @@ package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
entry := &config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://opencode.ai/zen/go/v1",
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
}
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
if effort := got.Extra["effort"]; effort != "max" {
t.Fatalf("effort = %#v, want max", effort)
}
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
}
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
}
}
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
@@ -122,6 +149,81 @@ func TestResolveProviderSnapshotAliases(t *testing.T) {
}
}
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
t.Helper()
file := filepath.Join(t.TempDir(), "plan.json")
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
plan, err := repair.DecodeRepairPlan(raw)
if err != nil {
t.Fatal(err)
}
return file, plan
}
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, _ := writeDerivedStatePlan(t)
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
t.Fatal(err)
}
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
t.Fatalf("exit code = %d, want stale preview refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
t.Fatalf("exit code = %d, want confirmed preview application", got)
}
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
+16 -1
View File
@@ -3,8 +3,11 @@ package main
import (
"os"
"runtime/debug"
"reasonix/internal/cli"
"reasonix/internal/config"
"reasonix/internal/crashreport"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/anthropic"
@@ -15,6 +18,18 @@ import (
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
var runCLI = cli.Run
func main() {
os.Exit(cli.Run(os.Args[1:], version))
os.Exit(runWithCrashCapture(os.Args[1:], version))
}
func runWithCrashCapture(args []string, buildVersion string) (exitCode int) {
defer func() {
if recovered := recover(); recovered != nil {
_ = crashreport.CapturePanic(config.ReasonixHomeDir(), buildVersion, recovered, debug.Stack())
panic(recovered)
}
}()
return runCLI(args, buildVersion)
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/crashreport"
)
func TestRunWithCrashCaptureRecordsAndReraises(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
previous := runCLI
t.Cleanup(func() { runCLI = previous })
secret := "private prompt from panic"
runCLI = func([]string, string) int { panic(secret) }
func() {
defer func() {
if recovered := recover(); recovered != secret {
t.Fatalf("reraised panic = %#v", recovered)
}
}()
runWithCrashCapture([]string{"run"}, "v1.20.0")
}()
reports, err := crashreport.List(config.ReasonixHomeDir())
if err != nil || len(reports) != 1 {
t.Fatalf("captured reports=%d err=%v", len(reports), err)
}
if got := reports[0].Report.TopFrame; !strings.Contains(got, "main_test.go:") {
t.Fatalf("top frame = %q, want panic call site in main_test.go", got)
}
preview, err := crashreport.Preview(reports[0].Report)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(preview), secret) {
t.Fatalf("panic value leaked into report: %s", preview)
}
}
func TestRunWithCrashCapturePassesThroughExitCode(t *testing.T) {
previous := runCLI
t.Cleanup(func() { runCLI = previous })
runCLI = func(args []string, version string) int {
if len(args) != 1 || args[0] != "version" || version != "v1.20.0" {
t.Fatalf("runCLI args=%v version=%q", args, version)
}
return 17
}
if got := runWithCrashCapture([]string{"version"}, "v1.20.0"); got != 17 {
t.Fatalf("exit code=%d", got)
}
}
+389
View File
@@ -0,0 +1,389 @@
package main
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
const (
contractPath = ".signpath/contracts/release-signing.yml"
projectSlug = "DeepSeek-Reasonix"
policySlug = "release-signing"
repository = "https://github.com/esengine/DeepSeek-Reasonix.git"
)
var expectedBranches = []string{"main-v2"}
type releaseSigningContract struct {
Version int `yaml:"version"`
ProjectSlug string `yaml:"project_slug"`
SigningPolicySlug string `yaml:"signing_policy_slug"`
RepositoryURL string `yaml:"repository_url"`
AllowedBranchNames []string `yaml:"allowed_branch_names"`
AllowedBuildDefinitions []string `yaml:"allowed_build_definitions"`
FingerprintFiles []string `yaml:"fingerprint_files"`
}
type workflowInfo struct {
externallyTriggered bool
directSigning bool
calls []string
}
func main() {
if len(os.Args) < 2 || len(os.Args) > 3 {
fatalf("usage: signpath-contract <validate|fingerprint> [repository-root]")
}
root := "."
if len(os.Args) == 3 {
root = os.Args[2]
}
contract, err := loadAndValidate(root)
if err != nil {
fatalf("%v", err)
}
switch os.Args[1] {
case "validate":
fmt.Println("SignPath release signing contract is valid")
case "fingerprint":
fingerprint, err := contractFingerprint(root, contract)
if err != nil {
fatalf("fingerprint contract: %v", err)
}
fmt.Printf("v1:%x\n", fingerprint)
default:
fatalf("unknown command %q", os.Args[1])
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "signpath-contract: "+format+"\n", args...)
os.Exit(1)
}
func loadAndValidate(root string) (releaseSigningContract, error) {
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(contractPath)))
if err != nil {
return releaseSigningContract{}, fmt.Errorf("read %s: %w", contractPath, err)
}
var contract releaseSigningContract
if err := yaml.Unmarshal(data, &contract); err != nil {
return releaseSigningContract{}, fmt.Errorf("parse %s: %w", contractPath, err)
}
if err := validateContract(root, contract); err != nil {
return releaseSigningContract{}, err
}
return contract, nil
}
func validateContract(root string, contract releaseSigningContract) error {
if contract.Version != 1 {
return fmt.Errorf("%s version is %d, want 1", contractPath, contract.Version)
}
if contract.ProjectSlug != projectSlug {
return fmt.Errorf("project_slug is %q, want %q", contract.ProjectSlug, projectSlug)
}
if contract.SigningPolicySlug != policySlug {
return fmt.Errorf("signing_policy_slug is %q, want %q", contract.SigningPolicySlug, policySlug)
}
if contract.RepositoryURL != repository {
return fmt.Errorf("repository_url is %q, want %q", contract.RepositoryURL, repository)
}
if err := requireExactSet("allowed_branch_names", contract.AllowedBranchNames, expectedBranches); err != nil {
return err
}
if len(contract.AllowedBuildDefinitions) == 0 {
return errors.New("allowed_build_definitions must not be empty")
}
for _, value := range append(append([]string{}, contract.AllowedBranchNames...), contract.AllowedBuildDefinitions...) {
if strings.ContainsAny(value, "*?[") {
return fmt.Errorf("SignPath allowlists must not contain wildcard %q", value)
}
}
for _, name := range contract.AllowedBuildDefinitions {
if err := validateRepositoryPath(name); err != nil {
return fmt.Errorf("invalid allowed build definition %q: %w", name, err)
}
if !strings.HasPrefix(name, ".github/workflows/") {
return fmt.Errorf("allowed build definition %q is outside .github/workflows", name)
}
}
if err := requireUnique("allowed_build_definitions", contract.AllowedBuildDefinitions); err != nil {
return err
}
if len(contract.FingerprintFiles) == 0 {
return errors.New("fingerprint_files must not be empty")
}
seen := make(map[string]bool, len(contract.FingerprintFiles))
for _, name := range contract.FingerprintFiles {
if err := validateRepositoryPath(name); err != nil {
return fmt.Errorf("invalid fingerprint file %q: %w", name, err)
}
if seen[name] {
return fmt.Errorf("duplicate fingerprint file %q", name)
}
seen[name] = true
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
return fmt.Errorf("fingerprint file %q: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("fingerprint file %q is not a regular file", name)
}
}
reachable, err := discoverTopLevelSigningWorkflows(root)
if err != nil {
return err
}
if err := requireExactSet("top-level workflows that reach SignPath", reachable, contract.AllowedBuildDefinitions); err != nil {
return fmt.Errorf("SignPath build-definition drift: %w", err)
}
return nil
}
func requireExactSet(name string, got, want []string) error {
gotCopy := append([]string(nil), got...)
wantCopy := append([]string(nil), want...)
sort.Strings(gotCopy)
sort.Strings(wantCopy)
if len(gotCopy) != len(wantCopy) {
return fmt.Errorf("%s is %v, want %v", name, got, want)
}
for i := range gotCopy {
if gotCopy[i] != wantCopy[i] {
return fmt.Errorf("%s is %v, want %v", name, got, want)
}
if i > 0 && gotCopy[i] == gotCopy[i-1] {
return fmt.Errorf("%s contains duplicate %q", name, gotCopy[i])
}
}
return nil
}
func requireUnique(name string, values []string) error {
seen := make(map[string]bool, len(values))
for _, value := range values {
if seen[value] {
return fmt.Errorf("%s contains duplicate %q", name, value)
}
seen[value] = true
}
return nil
}
func validateRepositoryPath(name string) error {
if name == "" || filepath.IsAbs(filepath.FromSlash(name)) {
return errors.New("path must be non-empty and repository-relative")
}
clean := filepath.Clean(filepath.FromSlash(name))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return errors.New("path escapes the repository")
}
if filepath.ToSlash(clean) != name {
return errors.New("path must be normalized with forward slashes")
}
return nil
}
func discoverTopLevelSigningWorkflows(root string) ([]string, error) {
paths, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.y*ml"))
if err != nil {
return nil, fmt.Errorf("list workflows: %w", err)
}
workflows := make(map[string]workflowInfo, len(paths))
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read workflow %s: %w", path, err)
}
name, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
name = filepath.ToSlash(name)
info, err := parseWorkflow(data)
if err != nil {
return nil, fmt.Errorf("parse workflow %s: %w", name, err)
}
workflows[name] = info
}
state := make(map[string]uint8, len(workflows))
memo := make(map[string]bool, len(workflows))
var reachesSigning func(string) (bool, error)
reachesSigning = func(name string) (bool, error) {
if state[name] == 2 {
return memo[name], nil
}
if state[name] == 1 {
return false, fmt.Errorf("reusable workflow cycle includes %s", name)
}
info, ok := workflows[name]
if !ok {
return false, fmt.Errorf("workflow calls missing local workflow %s", name)
}
state[name] = 1
reachable := info.directSigning
for _, called := range info.calls {
calledReachable, err := reachesSigning(called)
if err != nil {
return false, err
}
reachable = reachable || calledReachable
}
state[name] = 2
memo[name] = reachable
return reachable, nil
}
var result []string
for name, info := range workflows {
if !info.externallyTriggered {
continue
}
reachable, err := reachesSigning(name)
if err != nil {
return nil, err
}
if reachable {
result = append(result, name)
}
}
sort.Strings(result)
return result, nil
}
func parseWorkflow(data []byte) (workflowInfo, error) {
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
return workflowInfo{}, err
}
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return workflowInfo{}, errors.New("workflow root must be a mapping")
}
root := document.Content[0]
on := mappingValue(root, "on")
jobs := mappingValue(root, "jobs")
if on == nil || jobs == nil || jobs.Kind != yaml.MappingNode {
return workflowInfo{}, errors.New("workflow must contain on and jobs mappings")
}
info := workflowInfo{externallyTriggered: hasExternalTrigger(on)}
for i := 1; i < len(jobs.Content); i += 2 {
job := jobs.Content[i]
if job.Kind != yaml.MappingNode {
continue
}
if uses := mappingScalar(job, "uses"); strings.HasPrefix(uses, "./.github/workflows/") {
info.calls = append(info.calls, strings.TrimPrefix(uses, "./"))
}
if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") {
info.directSigning = true
}
steps := mappingValue(job, "steps")
if steps == nil || steps.Kind != yaml.SequenceNode {
continue
}
for _, step := range steps.Content {
if step.Kind != yaml.MappingNode {
continue
}
if strings.HasPrefix(mappingScalar(step, "uses"), "signpath/github-action-submit-signing-request@") {
info.directSigning = true
}
}
}
sort.Strings(info.calls)
return info, nil
}
func nodeContains(node *yaml.Node, text string) bool {
if node == nil {
return false
}
if node.Kind == yaml.ScalarNode && strings.Contains(node.Value, text) {
return true
}
for _, child := range node.Content {
if nodeContains(child, text) {
return true
}
}
return false
}
func hasExternalTrigger(node *yaml.Node) bool {
switch node.Kind {
case yaml.ScalarNode:
return node.Value != "workflow_call"
case yaml.SequenceNode:
for _, child := range node.Content {
if child.Value != "workflow_call" {
return true
}
}
case yaml.MappingNode:
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value != "workflow_call" {
return true
}
}
}
return false
}
func mappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == key {
return node.Content[i+1]
}
}
return nil
}
func mappingScalar(node *yaml.Node, key string) string {
value := mappingValue(node, key)
if value == nil || value.Kind != yaml.ScalarNode {
return ""
}
return value.Value
}
func contractFingerprint(root string, contract releaseSigningContract) ([sha256.Size]byte, error) {
names := append([]string{contractPath}, contract.FingerprintFiles...)
sort.Strings(names)
hash := sha256.New()
_, _ = io.WriteString(hash, "reasonix-signpath-release-contract-v1\x00")
for _, name := range names {
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
return [sha256.Size]byte{}, fmt.Errorf("read %s: %w", name, err)
}
writeLengthPrefixed(hash, []byte(name))
writeLengthPrefixed(hash, data)
}
var result [sha256.Size]byte
copy(result[:], hash.Sum(nil))
return result, nil
}
func writeLengthPrefixed(writer io.Writer, data []byte) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(data)))
_, _ = writer.Write(length[:])
_, _ = writer.Write(data)
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"fmt"
"reflect"
"testing"
)
func TestRepositoryReleaseSigningContract(t *testing.T) {
root := "../.."
contract, err := loadAndValidate(root)
if err != nil {
t.Fatal(err)
}
fingerprint, err := contractFingerprint(root, contract)
if err != nil {
t.Fatal(err)
}
if got := fmt.Sprintf("%x", fingerprint); len(got) != 64 {
t.Fatalf("fingerprint length = %d, want 64", len(got))
}
}
func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
got, err := discoverTopLevelSigningWorkflows("../..")
if err != nil {
t.Fatal(err)
}
want := []string{
".github/workflows/release-desktop.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("top-level workflows that reach SignPath = %v, want %v", got, want)
}
}
func TestReleaseSigningContractRejectsWildcards(t *testing.T) {
contract, err := loadAndValidate("../..")
if err != nil {
t.Fatal(err)
}
contract.AllowedBuildDefinitions = []string{".github/workflows/release-*.yml"}
if err := validateContract("../..", contract); err == nil {
t.Fatal("wildcard build definition unexpectedly passed validation")
}
}
func TestWorkflowUsingSignPathTokenIsSigningEntryPoint(t *testing.T) {
workflow := []byte(`
on: workflow_dispatch
jobs:
sign:
runs-on: windows-latest
steps:
- shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
run: ./submit-signing-request.ps1
`)
info, err := parseWorkflow(workflow)
if err != nil {
t.Fatal(err)
}
if !info.externallyTriggered || !info.directSigning {
t.Fatalf("token-backed workflow was not classified as a signing entry point: %+v", info)
}
}
+1
View File
@@ -28,6 +28,7 @@
/build/linux/*
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/io.reasonix.desktop.update.policy
!/build/linux/icons
!/build/linux/icons/**
+23 -9
View File
@@ -137,9 +137,19 @@ The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
installer (no admin rights needed).
- **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace
the binaries in the install directory, and relaunch through Guard. No elevation.
- **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator
authorization via Polkit (`pkexec`), re-verify and install with `apt-get
--only-upgrade`, then relaunch through Guard. The first build that ships the
update helper and Polkit policy is a one-time bootstrap: existing `.deb` users
should overwrite-install once with
`sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After
that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use
the same manual command. Failed installs leave the running app intact so you
can retry; successful installs are managed by apt/dpkg and are not auto-downgraded.
- **Windows** — download, verify the minisign signature, then run the per-user
NSIS installer (no admin rights needed).
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
in-place swap would be blocked by Gatekeeper; the banner links to the download
page for a manual update instead.
@@ -272,16 +282,20 @@ desktop/
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
a random install id (generated locally, tied to nothing), app version, OS,
arch, and OS version. It exists solely to count active installs. It never
includes conversations, API keys, file contents, or paths.
arch, and OS version. When the previous process ended abnormally, the next
normal launch may also send a bounded native diagnostic (lifecycle phase,
symbolized stack, WebView2/window failure kind, and coarse device facts).
Panic values are removed and paths/secrets are scrubbed before the report is
queued. It never includes conversations, API keys, or file contents.
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
`telemetry = false` under `[desktop]` in the global config. Dev builds
never ping. Crash and performance-pressure reports are separate and only
ever sent when the user clicks "Send report" on the diagnostic UI.
never ping or upload queued native diagnostics. Frontend crash and
performance-pressure reports remain separate and are sent only when the user
clicks "Send report" on the diagnostic UI.
Aggregate quality metrics are also enabled by default and can be disabled from
Settings > Updates > "Share aggregate quality metrics", or by setting
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
counts and preference buckets; they never include conversations, prompts, keys,
paths, base URLs, or file contents.
counts, lifecycle/window failure buckets, and preference buckets; they never
include conversations, prompts, keys, paths, base URLs, or file contents.
+1469 -371
View File
File diff suppressed because it is too large. Load diff
+520 -58
View File
@@ -27,6 +27,8 @@ import (
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/instruction"
"reasonix/internal/jobs"
"reasonix/internal/mcplaunch"
"reasonix/internal/memory"
@@ -39,6 +41,44 @@ import (
"reasonix/internal/tool"
)
type todoMetaController struct {
control.SessionAPI
todos []evidence.TodoItem
}
func (c *todoMetaController) Todos() []evidence.TodoItem {
return append([]evidence.TodoItem(nil), c.todos...)
}
func TestCanonicalTodosMetaWireContract(t *testing.T) {
if got := ctrlTodos(nil); got != nil {
t.Fatalf("nil controller todos = %+v, want unavailable", *got)
}
empty := Meta{CanonicalTodos: ctrlTodos(&todoMetaController{})}
raw, err := json.Marshal(empty)
if err != nil {
t.Fatalf("marshal empty canonical todos: %v", err)
}
if !strings.Contains(string(raw), `"canonicalTodos":[]`) {
t.Fatalf("empty canonical todos must encode as an authoritative empty array: %s", raw)
}
ctrl := &todoMetaController{todos: []evidence.TodoItem{{Content: "Ship", Status: "completed"}}}
got := ctrlTodos(ctrl)
if got == nil || len(*got) != 1 || (*got)[0].Status != "completed" {
t.Fatalf("canonical todos = %+v, want completed task", got)
}
unavailable, err := json.Marshal(Meta{CanonicalTodos: ctrlTodos(nil)})
if err != nil {
t.Fatalf("marshal unavailable canonical todos: %v", err)
}
if strings.Contains(string(unavailable), "canonicalTodos") {
t.Fatalf("unavailable canonical todos should preserve the legacy fallback contract: %s", unavailable)
}
}
func TestPluginToolsToViewPreservesSchemaError(t *testing.T) {
got := pluginToolsToView([]plugin.ToolInfo{{
Name: "generate_yso_bytes", Description: "Generate payload", ReadOnlyHint: true,
@@ -434,7 +474,9 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
}
deadline := time.Now().Add(time.Second)
// The first git process launch can be noticeably slower on Windows runners
// while MetaForTab intentionally keeps the caller path non-blocking.
deadline := time.Now().Add(5 * time.Second)
for {
if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
break
@@ -557,18 +599,78 @@ func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
isolateDesktopUserDirs(t)
view := NewApp().Memory()
if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {
if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil || view.InstructionDiagnostics == nil || view.Conflicts == nil || view.LastRecall.Hits == nil {
t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
}
raw, err := json.Marshal(view)
if err != nil {
t.Fatalf("marshal Memory(): %v", err)
}
for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} {
for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`, `"instructionDiagnostics":null`, `"conflicts":null`, `"hits":null`} {
if strings.Contains(string(raw), bad) {
t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
}
}
if revisions := NewApp().MemoryRevisions("missing"); revisions == nil {
t.Fatal("MemoryRevisions must return [] before startup, not nil")
}
}
func TestMemoryViewIncludesRecallFreshnessAndOverrides(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
store := memory.Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
Name: "deploy-target", Title: "Deploy target", Description: "legacy deployment target", Scope: memory.FactScopeGlobal, Type: memory.TypeProject, Body: "Deploy payments to the legacy cluster.",
}); err != nil {
t.Fatal(err)
}
if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
Name: "deploy-target", Title: "Deploy target", Description: "current deployment target", Scope: memory.FactScopeProject, Type: memory.TypeProject, Body: "Deploy payments to the green cluster.",
}); err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{Memory: &memory.Set{Store: store}})
ctrl.Compose("deploy payments target cluster")
app := NewApp()
app.setTestCtrl(ctrl, "test-model")
view := app.Memory()
if len(view.Facts) != 2 || view.Facts[0].Freshness == "" || view.Facts[1].Freshness == "" {
t.Fatalf("facts with freshness = %+v", view.Facts)
}
if len(view.Conflicts) != 1 || view.Conflicts[0].Resolution != "project_over_global" {
t.Fatalf("conflicts = %+v", view.Conflicts)
}
if view.LastRecall.Query != "deploy payments target cluster" || len(view.LastRecall.Hits) != 1 || view.LastRecall.Hits[0].Scope != "project" {
t.Fatalf("last recall = %+v", view.LastRecall)
}
}
func TestMemoryRevisionAPIRestoresSelectedRevision(t *testing.T) {
isolateDesktopUserDirs(t)
store := memory.Store{Dir: t.TempDir()}
first, err := store.SaveWithOptions(memory.Memory{Name: "fact", Description: "one", Body: "v1"}, memory.SaveOptions{})
if err != nil {
t.Fatal(err)
}
if _, err := store.SaveWithOptions(memory.Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, memory.SaveOptions{}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store}}), "test-model")
revisions := app.MemoryRevisions(first.Memory.ID)
if len(revisions) != 1 || revisions[0].Revision != 1 {
t.Fatalf("revisions = %+v", revisions)
}
restored, err := app.RestoreMemoryRevision(first.Memory.ID, 1)
if err != nil {
t.Fatal(err)
}
if restored.Revision != 3 || restored.Body != "v1" {
t.Fatalf("restored = %+v", restored)
}
}
func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
@@ -599,10 +701,12 @@ func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
app := NewApp()
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
Docs: []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}},
Store: store,
CWD: cwd,
UserDir: userDir,
Docs: []memory.Source{{
Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Directory: cwd,
Body: "Project instructions", Imports: []instruction.Import{{Path: filepath.Join(cwd, "shared.md"), SourcePath: filepath.Join(cwd, "AGENTS.md")}},
}},
InstructionDiagnostics: []instruction.Diagnostic{{Code: "import_cycle", Path: "shared.md", SourcePath: filepath.Join(cwd, "AGENTS.md"), Line: 3, Message: "cycle"}},
Store: store, CWD: cwd, UserDir: userDir,
}}), "test-model")
view := app.Memory()
@@ -612,18 +716,57 @@ func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
t.Fatalf("Memory() docs = %+v", view.Docs)
}
if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" {
if view.Docs[0].Directory != cwd || len(view.Docs[0].Imports) != 1 || len(view.InstructionDiagnostics) != 1 || view.InstructionDiagnostics[0].Code != "import_cycle" {
t.Fatalf("Memory() instruction provenance = docs %+v diagnostics %+v", view.Docs, view.InstructionDiagnostics)
}
if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" || view.Facts[0].Scope != "project" {
t.Fatalf("Memory() active facts = %+v", view.Facts)
}
if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" ||
if view.Facts[0].ID == "" || view.Facts[0].Revision != 1 || view.Facts[0].CreatedAt == "" || view.Facts[0].UpdatedAt == "" {
t.Fatalf("Memory() active fact metadata = %+v", view.Facts[0])
}
if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || view.Archives[0].Scope != "project" ||
view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
t.Fatalf("Memory() archived facts = %+v", view.Archives)
}
if view.Archives[0].ID == "" || view.Archives[0].Revision != 1 || view.Archives[0].CreatedAt == "" || view.Archives[0].UpdatedAt == "" {
t.Fatalf("Memory() archived fact metadata = %+v", view.Archives[0])
}
if len(view.Scopes) != 3 {
t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
}
}
func TestRestoreArchivedMemoryRecoversFactForCurrentSession(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
store := memory.StoreFor(userDir, cwd)
first, err := store.SaveWithOptions(memory.Memory{
Name: "restorable-fact", Description: "recover me", Body: "Recovered guidance.",
}, memory.SaveOptions{})
if err != nil {
t.Fatal(err)
}
archivePath, err := store.Archive(first.Memory.ID)
if err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}}), "test-model")
if _, err := app.RestoreArchivedMemory(archivePath); err != nil {
t.Fatal(err)
}
view := app.Memory()
if len(view.Facts) != 1 || view.Facts[0].ID != first.Memory.ID || view.Facts[0].Revision != 2 {
t.Fatalf("restored memory view = %+v", view)
}
if len(view.Archives) != 0 {
t.Fatalf("restored archive remained visible: %+v", view.Archives)
}
}
func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
isolateDesktopUserDirs(t)
consumeSystemQuitRequested()
@@ -903,6 +1046,9 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
t.Fatalf("set desktop check updates: %v", err)
}
if err := userCfg.SetDesktopUpdateChannel("preview"); err != nil {
t.Fatalf("set desktop update channel: %v", err)
}
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
@@ -911,7 +1057,7 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
}
got := NewApp().DesktopStartupSettings()
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates {
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "preview" {
t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
}
if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -2805,6 +2951,21 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
}
}
func TestModelsForTabListsNothingWhenProviderAccessExplicitlyEmpty(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
cfg := config.Default()
cfg.Desktop.ProviderAccess = []string{}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if models := NewApp().Models(); len(models) != 0 {
t.Fatalf("Models() = %+v, want no models when provider access is explicitly empty", models)
}
}
func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
@@ -3006,7 +3167,7 @@ func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
}
}
func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
func TestSetModelForTabRefreshesCarriedSystemPromptWithoutChangingDefaults(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
@@ -3050,9 +3211,18 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
sink: &tabEventSink{tabID: "tab_a", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
sibling := &WorkspaceTab{
ID: "tab_b",
Scope: "global",
Ready: true,
model: "old/old-model",
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab, sibling.ID: sibling}
app.tabOrder = []string{tab.ID, sibling.ID}
app.activeTabID = tab.ID
var switchTiming modelSwitchTiming
app.modelSwitchTimingHook = func(timing modelSwitchTiming) { switchTiming = timing }
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
@@ -3075,6 +3245,18 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
t.Fatalf("carried user message changed: %+v", history[1])
}
if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "old/old-model" {
t.Fatalf("default model after session switch = %q, want old/old-model", got)
}
if sibling.model != "old/old-model" {
t.Fatalf("sibling tab model after session switch = %q, want old/old-model", sibling.model)
}
if switchTiming.Outcome != "ok" || switchTiming.Total <= 0 {
t.Fatalf("model switch timing = %+v, want successful non-zero observation", switchTiming)
}
if switchTiming.Build <= 0 || switchTiming.LeaseAndResume <= 0 || switchTiming.SwapAndPersist <= 0 {
t.Fatalf("model switch stage timing incomplete: %+v", switchTiming)
}
}
// TestSetModelForTabRestoresSessionAuthorizations pins the fix for a model
@@ -5222,6 +5404,10 @@ func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
if got := currentTabTokenMode(tab); got != "full" {
t.Fatalf("token mode = %q, want full", got)
}
meta := app.MetaForTab(tab.ID)
if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
t.Fatalf("failed switch disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
}
}
func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
@@ -7147,7 +7333,7 @@ func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
}
}
func TestDesktopSharedHostProjectMCPWaitsForLaunchApproval(t *testing.T) {
func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T) {
if testing.Short() {
t.Skip("skipping background MCP boot integration test in short mode")
}
@@ -7183,15 +7369,16 @@ url = %q
defer ctrl.Close()
deadline := time.Now().Add(3 * time.Second)
for len(sharedHost.Failures()) == 0 && time.Now().Before(deadline) {
for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
time.Sleep(25 * time.Millisecond)
}
if sharedHost.HasClient("h") {
t.Fatal("project MCP connected before launch approval")
if !sharedHost.HasClient("h") {
t.Fatalf("project MCP did not connect automatically; failures=%+v", sharedHost.Failures())
}
failures := sharedHost.Failures()
if len(failures) != 1 || !failures[0].RequiresLaunchApproval || !strings.Contains(failures[0].Error, "until the user authorizes") {
t.Fatalf("project MCP failure = %+v", failures)
for _, failure := range sharedHost.Failures() {
if failure.Name == "h" && failure.RequiresLaunchApproval {
t.Fatalf("project MCP unexpectedly requested launch approval: %+v", failure)
}
}
app := NewApp()
@@ -7209,12 +7396,12 @@ url = %q
app.activeTabID = "test"
view := app.MCPServers()
if len(view) != 1 || view[0].Name != "h" || view[0].Status != "failed" || view[0].RuntimeState != "issue" || !view[0].RequiresLaunchApproval {
t.Fatalf("MCPServers() = %+v, want project h awaiting launch approval", view)
if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].RuntimeState != "ready" || view[0].RequiresLaunchApproval {
t.Fatalf("MCPServers() = %+v, want trusted connected project h", view)
}
}
func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
func TestProjectMCPViewIsTrustedAndKeepsProjectSource(t *testing.T) {
entry := config.PluginEntry{Name: "project", Source: config.MCPSourceProjectConfig}
connected := withPluginConfig(ServerView{Name: entry.Name, Status: "connected"}, entry)
if connected.RequiresLaunchApproval {
@@ -7223,8 +7410,11 @@ func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
blocked := withPluginConfig(ServerView{
Name: entry.Name, Status: "failed", RequiresLaunchApproval: true,
}, entry)
if !blocked.RequiresLaunchApproval {
t.Fatalf("blocked project MCP lost launch approval action: %+v", blocked)
if blocked.RequiresLaunchApproval {
t.Fatalf("project MCP exposed obsolete launch approval action: %+v", blocked)
}
if blocked.Source != "project" || blocked.ConfigSource != "reasonix.toml" {
t.Fatalf("blocked project MCP source = %q/%q, want project/reasonix.toml", blocked.Source, blocked.ConfigSource)
}
user := withPluginConfig(ServerView{Name: "user", Status: "connected"},
@@ -7232,6 +7422,9 @@ func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
if user.RequiresLaunchApproval {
t.Fatalf("user-config MCP must not be launch-gate governed: %+v", user)
}
if user.Source != "user" || user.ConfigSource != "config.toml" {
t.Fatalf("user MCP source = %q/%q, want user/config.toml", user.Source, user.ConfigSource)
}
}
func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
@@ -7304,6 +7497,42 @@ tier = "lazy"
t.Fatalf("time missing after disable: %+v", view.Servers)
}
func TestSetMCPServerEnabledRestoresOnDemandWithoutConnecting(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "offline"
type = "http"
url = "http://127.0.0.1:1/mcp"
`), 0o644); err != nil {
t.Fatal(err)
}
host := plugin.NewHost()
defer host.Close()
reg := tool.NewRegistry()
ctrl := control.New(control.Options{Host: host, Registry: reg, PluginCtx: context.Background(), WorkspaceRoot: dir})
app := NewApp()
app.setTestCtrl(ctrl, "")
app.tabs["test"].WorkspaceRoot = dir
if err := app.SetMCPServerEnabled("offline", false); err != nil {
t.Fatalf("SetMCPServerEnabled(false): %v", err)
}
if err := app.SetMCPServerEnabled("offline", true); err != nil {
t.Fatalf("SetMCPServerEnabled(true) forced an unavailable connection: %v", err)
}
if host.HasClient("offline") {
t.Fatal("durable enable started the disconnected MCP server")
}
if _, ok := reg.Get("mcp__offline__connect"); !ok {
t.Fatalf("on-demand connect stub missing after enable; names=%v", reg.Names())
}
}
func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -8455,6 +8684,7 @@ args = ["serve"]
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
app.activeTab().disabledMCP["time"] = ServerView{Name: "time", Status: "disabled", Enabled: false}
if err := app.UpdateMCPServer("time", MCPServerInput{
Name: "time",
@@ -8485,6 +8715,71 @@ args = ["serve"]
}
}
func TestRemoveProjectMCPRevealsAndRegistersGlobalFallback(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
userCfg := config.LoadForEdit(config.UserConfigPath())
userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "reasonix.toml")
if err := os.WriteFile(projectPath, []byte(`
[[plugins]]
name = "docs"
command = "project-docs"
`), 0o644); err != nil {
t.Fatal(err)
}
reg := tool.NewRegistry()
var configured []plugin.Spec
ctrl := control.New(control.Options{
Host: plugin.NewHost(),
Registry: reg,
WorkspaceRoot: dir,
MCPConfigureSpec: func(spec *plugin.Spec) {
configured = append(configured, *spec)
},
})
defer ctrl.Close()
projectEntry, found, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !found {
t.Fatalf("load project docs: entry=%+v found=%v err=%v", projectEntry, found, err)
}
if _, err := ctrl.RegisterMCPServerOnDemand(projectEntry); err != nil {
t.Fatalf("register project docs: %v", err)
}
app := NewApp()
app.setTestCtrl(ctrl, "")
app.activeTab().WorkspaceRoot = dir
if err := app.RemoveMCPServer("docs"); err != nil {
t.Fatalf("RemoveMCPServer(docs): %v", err)
}
projectCfg := config.LoadForEdit(projectPath)
if _, found := findPluginEntry(projectCfg.Plugins, "docs"); found {
t.Fatalf("project docs still configured after removal: %+v", projectCfg.Plugins)
}
globalCfg := config.LoadForEdit(config.UserConfigPath())
globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
if !found || globalEntry.Command != "global-docs" {
t.Fatalf("global docs fallback = %+v, found=%v", globalEntry, found)
}
effective, found, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !found || effective.Source != config.MCPSourceUserConfig || effective.Command != "global-docs" {
t.Fatalf("effective docs fallback = %+v, found=%v err=%v", effective, found, err)
}
if len(configured) < 2 || configured[len(configured)-1].Command != "global-docs" {
t.Fatalf("registered specs = %+v, want global fallback registered last", configured)
}
if _, found := reg.Get("mcp__docs__connect"); !found {
t.Fatalf("global fallback connect surface missing; names=%v", reg.Names())
}
}
func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -8668,6 +8963,14 @@ func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
entry, ok, err := desktopEffectiveMCPServer(dir, "codegraph")
if err != nil || !ok {
t.Fatalf("load codegraph entry: found=%v err=%v", ok, err)
}
if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
t.Fatal(err)
}
app.activeTab().disabledMCP["codegraph"] = ServerView{}
if err := app.UpdateMCPServer("codegraph", MCPServerInput{
Name: "codegraph",
@@ -8702,6 +9005,59 @@ func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
}
}
func TestUpdateMCPServerPreservesProjectTOMLSourceAndGlobalShadow(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
userCfg := config.LoadForEdit(config.UserConfigPath())
userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "reasonix.toml")
if err := os.WriteFile(projectPath, []byte(`
[[plugins]]
name = "docs"
command = "project-docs"
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir}), "")
defer app.activeCtrl().Close()
app.activeTab().WorkspaceRoot = dir
entry, ok, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !ok || entry.Source != config.MCPSourceProjectConfig {
t.Fatalf("load project docs entry: entry=%+v found=%v err=%v", entry, ok, err)
}
if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
t.Fatal(err)
}
app.activeTab().disabledMCP["docs"] = ServerView{}
if err := app.UpdateMCPServer("docs", MCPServerInput{
Name: "docs", Transport: "stdio", Command: "project-docs-updated",
}); err != nil {
t.Fatalf("UpdateMCPServer(project reasonix.toml docs): %v", err)
}
projectCfg := config.LoadForEdit(projectPath)
projectEntry, found := findPluginEntry(projectCfg.Plugins, "docs")
if !found || projectEntry.Command != "project-docs-updated" {
t.Fatalf("project docs entry = %+v, found=%v", projectEntry, found)
}
globalCfg := config.LoadForEdit(config.UserConfigPath())
globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
if !found || globalEntry.Command != "global-docs" {
t.Fatalf("global shadow changed while editing project entry: %+v, found=%v", globalEntry, found)
}
effective, found, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !found || effective.Source != config.MCPSourceProjectConfig || effective.Command != "project-docs-updated" {
t.Fatalf("effective docs after edit = %+v, found=%v err=%v", effective, found, err)
}
}
func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -8757,6 +9113,67 @@ func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
t.Fatalf("stripe MCP missing from view: %+v", view)
}
func TestInstallMCPServerHandshakeFailureDoesNotPersist(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
result, err := app.InstallMCPServer(MCPServerInput{
Name: "broken", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
})
if err != nil {
t.Fatalf("InstallMCPServer returned transport error instead of structured issue: %v", err)
}
if result.State != "issue" || result.Action != "retry" {
t.Fatalf("install result = %+v, want retryable issue", result)
}
cfg, err := config.LoadForRoot(dir)
if err != nil {
t.Fatal(err)
}
if _, ok := findPluginEntry(cfg.Plugins, "broken"); ok {
t.Fatalf("failed candidate was persisted: %+v", cfg.Plugins)
}
for _, server := range app.MCPServers() {
if server.Name == "broken" {
t.Fatalf("failed candidate leaked into the installed server list: %+v", server)
}
}
}
func TestInstallMCPServerAuthenticationRequiredPersistsForResume(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
result, err := app.InstallMCPServer(MCPServerInput{Name: "oauth", Transport: "http", URL: srv.URL})
if err != nil {
t.Fatalf("InstallMCPServer auth result: %v", err)
}
if result.State != "action_required" || result.Action != "authenticate" {
t.Fatalf("install result = %+v, want authentication action", result)
}
cfg, err := config.LoadForRoot(dir)
if err != nil {
t.Fatal(err)
}
if _, ok := findPluginEntry(cfg.Plugins, "oauth"); !ok {
t.Fatalf("auth-pending candidate must persist for resume: %+v", cfg.Plugins)
}
}
func TestAddMCPServerPersistsConnectionConfiguration(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -8855,6 +9272,39 @@ func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T)
}
}
func TestUpdateMCPServerFailedCandidateRollsBackConfigAndConnection(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
srv := desktopMCPHTTPServer(t)
defer srv.Close()
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
if _, err := app.AddMCPServer(MCPServerInput{Name: "stable", Transport: "http", URL: srv.URL}); err != nil {
t.Fatal(err)
}
err := app.UpdateMCPServer("stable", MCPServerInput{
Name: "stable", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
})
if err == nil {
t.Fatal("broken update candidate should fail")
}
cfg, err := config.LoadForRoot(dir)
if err != nil {
t.Fatal(err)
}
entry, ok := findPluginEntry(cfg.Plugins, "stable")
if !ok || entry.Type != "http" || entry.URL != srv.URL {
t.Fatalf("failed update changed durable config: %+v, found=%v", entry, ok)
}
if !app.activeCtrl().Host().HasClient("stable") {
t.Fatal("previous MCP connection was not restored after failed update")
}
}
func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -9008,7 +9458,7 @@ tier = "lazy"
t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
}
func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
func TestUpdateMCPServerMigratesLegacyTierInProjectSource(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
@@ -9030,6 +9480,14 @@ tier = "lazy"
c.Close()
}
}()
entry, ok, err := desktopEffectiveMCPServer(dir, "playwright")
if err != nil || !ok {
t.Fatalf("load playwright entry: found=%v err=%v", ok, err)
}
if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
t.Fatal(err)
}
app.activeTab().disabledMCP["playwright"] = ServerView{Name: "playwright", Status: "disabled", Enabled: false}
if err := app.UpdateMCPServer("playwright", MCPServerInput{
Name: "playwright",
@@ -9050,25 +9508,25 @@ tier = "lazy"
t.Fatalf("env TOKEN = %q, want preserved env", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
if !ok {
t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("user plugin after migration = %+v", userPlugin)
}
if userPlugin.Tier != "" {
t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
if _, ok := findPluginEntry(userCfg.Plugins, "playwright"); ok {
t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "playwright")
if !ok {
t.Fatalf("playwright should remain in project config: %+v", projectCfg.Plugins)
}
if projectPlugin.Command != "node" || projectPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("project plugin after update = %+v", projectPlugin)
}
if projectPlugin.Tier != "" {
t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "failed" {
t.Fatalf("updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v", s.Status, s)
if s.Status != "disabled" {
t.Fatalf("updated MCP status = %q, want disabled without a readiness probe; server = %+v", s.Status, s)
}
if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
t.Fatalf("server command not refreshed: %+v", s)
@@ -9117,14 +9575,14 @@ args = ["-y", "@playwright/mcp"]
}
}
func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
func TestUpdateMCPServerRejectsReconnectFailureWithoutPersisting(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "broken"
command = "npx"
command = "reasonix-old-missing-mcp-binary"
tier = "background"
`), 0o644); err != nil {
t.Fatal(err)
@@ -9138,18 +9596,18 @@ tier = "background"
Name: "broken",
Transport: "stdio",
Command: "reasonix-missing-mcp-binary",
}); err != nil {
t.Fatalf("UpdateMCPServer should persist config even when reconnect fails: %v", err)
}); err == nil {
t.Fatal("UpdateMCPServer should reject an unusable candidate")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if got := cfg.Plugins[0].Command; got != "reasonix-missing-mcp-binary" {
t.Fatalf("updated command = %q, want missing binary", got)
if got := cfg.Plugins[0].Command; got != "reasonix-old-missing-mcp-binary" {
t.Fatalf("failed update command = %q, want original command", got)
}
if got := cfg.Plugins[0].Tier; got != "" {
t.Fatalf("updated tier = %q, want migrated empty", got)
t.Fatalf("loaded legacy tier = %q, want normalized empty", got)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
@@ -9160,8 +9618,8 @@ tier = "background"
if s.Status != "failed" {
t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
}
if s.Command != "reasonix-missing-mcp-binary" || s.Tier != "background" {
t.Fatalf("server config not refreshed after failed reconnect: %+v", s)
if s.Command != "reasonix-old-missing-mcp-binary" || s.Tier != "background" {
t.Fatalf("failed candidate leaked into server config: %+v", s)
}
return
}
@@ -9225,7 +9683,7 @@ name = "codegraph"
t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
}
func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
func TestSetMCPServerTierPreservesProjectSourceAndRecordsConnectFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
@@ -9257,16 +9715,16 @@ tier = "lazy"
t.Fatalf("saved tier = %q, want migrated empty", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
if !ok {
t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Tier != "" {
t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
if _, ok := findPluginEntry(userCfg.Plugins, "broken"); ok {
t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "broken")
if !ok {
t.Fatalf("broken should remain in project config: %+v", projectCfg.Plugins)
}
if projectPlugin.Tier != "" {
t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
@@ -9573,7 +10031,11 @@ func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath s
func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
t.Helper()
deadline := time.Now().Add(time.Second)
// Windows release runners can take more than one second to schedule the
// controller's asynchronous completion while the full desktop suite is
// active. Keep a bounded responsiveness check without treating scheduler
// delay as a leaked controller.
deadline := time.Now().Add(5 * time.Second)
for ctrl.Running() {
if time.Now().After(deadline) {
t.Fatal("controller still running")
+10 -4
View File
@@ -185,7 +185,7 @@ func (h *botBridgeHub) observe(tabID string, e event.Event) {
// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
return
}
h.enqueue(h.turnDoneNotification(tabID, e.Err))
h.enqueue(h.turnDoneNotification(tabID, e))
}
}
@@ -359,15 +359,21 @@ func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridg
return desktopBridgeNotification{text: textFor, card: card}
}
func (h *botBridgeHub) turnDoneNotification(tabID string, err error) desktopBridgeNotification {
func (h *botBridgeHub) turnDoneNotification(tabID string, e event.Event) desktopBridgeNotification {
label := h.tabLabel(tabID)
if err != nil {
if e.Outcome == event.TurnOutcomeRecoveryPaused {
return desktopBridgeNotification{text: constText(fmt.Sprintf(
"⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
label,
))}
}
if e.Err != nil {
// Error text can contain paths/tokens; only detail it in a private chat.
return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
if isSharedChat(route.ChatType) {
return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
}
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(err.Error(), botBridgeErrTextLimit))
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
}}
}
return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
+15
View File
@@ -357,6 +357,21 @@ func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
}
}
func TestBridgeRecoveryPauseNotifiesAsControlledPause(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-1", event.Event{
Kind: event.TurnDone,
Err: errors.New("automatic recovery paused"),
Outcome: event.TurnOutcomeRecoveryPaused,
})
call := env.waitNotification(t)
if strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "已暂停自动重试") || !strings.Contains(call.msg.Text, "继续") {
t.Fatalf("recovery pause text = %q, want a neutral actionable pause notice", call.msg.Text)
}
}
func TestBridgeAskAnswerRoundTrip(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
env.hub.SetWatch(testWatchRoute(), true)
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Polkit policy for Reasonix desktop in-app .deb upgrades.
The action only authorizes the fixed root-owned helper with argv1=install.
Active sessions require auth_admin every time; authorization is not retained.
-->
<policyconfig>
<vendor>Reasonix</vendor>
<vendor_url>https://reasonix.io</vendor_url>
<action id="io.reasonix.desktop.update">
<description gettext-domain="reasonix-desktop">Install a Reasonix desktop update</description>
<description xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 桌面端更新</description>
<description xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 桌面端更新</description>
<message gettext-domain="reasonix-desktop">Authentication is required to install a Reasonix update</message>
<message xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 更新需要管理员授权</message>
<message xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 更新需要管理員授權</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/reasonix/reasonix-update-helper</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">install</annotate>
</action>
</policyconfig>
+17 -6
View File
@@ -1,12 +1,13 @@
# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
# below are relative to desktop/). The .deb is a human-download artifact only — like
# the macOS .dmg — so the Linux updater channel stays the .tar.gz and cmd/sign's
# manifest skips .deb files.
# below are relative to desktop/). Portable clients keep using the .tar.gz under
# platforms[]; this .deb is the native package channel (native_packages) and also
# remains a human-download artifact.
#
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION is the tag
# minus a leading "v" and any prerelease suffix (a strict X.Y.Z that dpkg accepts);
# DEB_ARCH is the Go arch name, which already matches Debian's (amd64, arm64).
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION follows
# Debian version ordering: stable X.Y.Z, prereleases as X.Y.Z~rc.N / X.Y.Z~canary.N
# so they sort below the matching stable package. DEB_ARCH is the Go arch name,
# which already matches Debian's (amd64, arm64).
name: "reasonix-desktop"
arch: "${DEB_ARCH}"
platform: "linux"
@@ -21,9 +22,11 @@ homepage: "https://github.com/esengine/DeepSeek-Reasonix"
license: "MIT"
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
# pkexec is required for the in-app Polkit authorization update path.
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
- pkexec
contents:
- src: ./build/bin/reasonix-desktop
dst: /usr/bin/reasonix-desktop
@@ -37,6 +40,14 @@ contents:
dst: /usr/bin/reasonix
file_info:
mode: 0755
- src: ./build/bin/reasonix-update-helper
dst: /usr/lib/reasonix/reasonix-update-helper
file_info:
mode: 0755
- src: ./build/linux/io.reasonix.desktop.update.policy
dst: /usr/share/polkit-1/actions/io.reasonix.desktop.update.policy
file_info:
mode: 0644
- src: ./build/linux/reasonix.desktop
dst: /usr/share/applications/reasonix.desktop
file_info:
+47 -3
View File
@@ -83,8 +83,13 @@ LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified updat
LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"
LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
## Preserve the first-pass generated uninstaller so the release workflow can
## Authenticode-sign it together with the other installed payload files.
## The second pass provides ARG_REASONIX_SIGNED_UNINSTALLER and embeds that
## signed binary instead of generating another unsigned uninstaller.
!ifndef ARG_REASONIX_SIGNED_UNINSTALLER
!uninstfinalize 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'
!endif
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
@@ -93,10 +98,13 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_GUARD "reasonix-guard.exe"
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_UNLOCK_RETRIES 60
Var ReasonixUpdateMode
Var ReasonixStageMode
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
@@ -106,7 +114,11 @@ ShowInstDetails show # This will always show the installation details.
## wails.deleteUninstaller, which write HKLM and would fail without admin rights.
####
!macro reasonix.writeUninstaller
!ifdef ARG_REASONIX_SIGNED_UNINSTALLER
File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"
!else
WriteUninstaller "$INSTDIR\uninstall.exe"
!endif
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
@@ -140,6 +152,7 @@ Function .onInit
; destination, then closes automatically after the file copy so the helper
; can relaunch Reasonix. A normal manual installer keeps the full wizard.
StrCpy $ReasonixUpdateMode "0"
StrCpy $ReasonixStageMode "0"
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/REASONIXUPDATE=" $R1
@@ -148,6 +161,13 @@ Function .onInit
StrCpy $ReasonixUpdateMode "1"
reasonix_update_mode_done:
ClearErrors
${GetOptions} $R0 "/REASONIXSTAGE=" $R2
IfErrors reasonix_stage_mode_done
StrCmp $R2 "1" 0 reasonix_stage_mode_done
StrCpy $ReasonixStageMode "1"
reasonix_stage_mode_done:
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
; missing. Older installers still wrote DisplayIcon, so use its parent folder
@@ -253,12 +273,30 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; Current auto-update helpers extract the signed installer payload into a
; unique temporary directory, then compare-and-publish the exact claimed
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
; in-place update behavior required to upgrade into the hardened release.
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
reasonix_copy_payload:
SetOutPath $INSTDIR
; The release workflow embeds a minisign-authenticated manifest after
; SignPath has signed the five executable members. Only the extraction
; handoff needs these files; a normal installation does not persist them.
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
reasonix_payload_metadata_done:
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
@@ -270,6 +308,7 @@ Section
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
; Portable archives expose Reasonix.exe as a second copy of the Guard
; launcher. Preserve that layout only when upgrading an existing portable
; directory, and keep the alias in lockstep with the canonical launcher.
@@ -282,9 +321,12 @@ reasonix_no_portable_entry:
; in-place upgrade from a launcher build that had no Windows resources.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
reasonix_launcher_payload_done:
!else
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
reasonix_launcher_payload_done_no_launcher:
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
@@ -292,10 +334,12 @@ reasonix_no_portable_entry:
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
StrCmp $ReasonixStageMode "1" reasonix_section_done
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
reasonix_section_done:
SectionEnd
Section "uninstall"
+90 -16
View File
@@ -14,6 +14,9 @@
// size + sha256, and write <dir>/latest.json with GitHub
// release download URLs. The R2 mirror step rewrites those
// URLs to the CDN afterwards (url + sig fields together).
//
// windows-payload <dir> <ver> Write a deterministic manifest of the exact
// executables embedded in the Windows installer.
package main
import (
@@ -37,6 +40,11 @@ import (
// generator and the updater agree on update.PlatformKey output.
var platforms = []string{"darwin-arm64", "darwin-amd64", "windows-amd64", "windows-arm64", "linux-amd64"}
var websiteDownloads = map[string]struct{}{
"Reasonix-darwin-universal.dmg": {},
"Reasonix-windows-amd64.zip": {},
}
func main() {
if len(os.Args) < 2 {
usage()
@@ -50,6 +58,11 @@ func main() {
usage()
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4])
case "windows-payload":
if len(os.Args) != 4 {
usage()
}
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
case "genkey":
if len(os.Args) != 3 {
usage()
@@ -70,10 +83,34 @@ func main() {
}
func usage() {
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag>\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
os.Exit(2)
}
func genWindowsPayloadManifest(dir, version string) error {
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("Windows payload %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("Windows payload %s is not a regular file", name)
}
_, sum, err := hashFile(path)
if err != nil {
return err
}
hashes[name] = sum
}
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
}
// verifyFile checks <file> against <file>.minisig using the embedded public key —
// the same check the updater runs before applying. A self-test that the signing
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
@@ -164,15 +201,21 @@ func signFiles(files []string) error {
// genManifest scans dir for the per-platform artifacts and writes dir/latest.json.
// version is the semver compared by the updater (e.g. "v1.1.0"); tag is the GitHub
// release tag used in download URLs (e.g. "desktop-v1.1.0").
//
// Portable updater channels land in platforms (tarballs/installers). Debian/Ubuntu
// .deb packages land only in native_packages so older clients keep resolving the
// tarball under platforms["linux-amd64"].
func genManifest(dir, version, tag string) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" || repo == "esengine/reasonix" {
repo = "esengine/DeepSeek-Reasonix"
}
m := update.Manifest{
Version: version,
DownloadPage: "https://reasonix.io/?download=desktop#start",
Platforms: map[string]update.Asset{},
Version: version,
DownloadPage: "https://reasonix.io/?download=desktop#start",
Platforms: map[string]update.Asset{},
NativePackages: map[string]update.Asset{},
Downloads: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
if err != nil {
@@ -183,8 +226,9 @@ func genManifest(dir, version, tag string) error {
if e.IsDir() || strings.HasSuffix(name, ".minisig") || name == "latest.json" {
continue
}
key := matchPlatform(name)
if key == "" {
key, kind := matchArtifact(name)
_, websiteDownload := websiteDownloads[name]
if key == "" && !websiteDownload {
continue
}
size, sum, err := hashFile(filepath.Join(dir, name))
@@ -192,12 +236,31 @@ func genManifest(dir, version, tag string) error {
return err
}
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
m.Platforms[key] = update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
asset := update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
if websiteDownload {
m.Downloads[name] = asset
fmt.Printf("manifest download: %s (%d bytes)\n", name, size)
}
if key != "" {
switch kind {
case artifactNative:
m.NativePackages[key] = asset
fmt.Printf("manifest native: %s -> %s (%d bytes)\n", key, name, size)
default:
m.Platforms[key] = asset
fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
}
}
}
if len(m.Platforms) == 0 {
return fmt.Errorf("manifest: no platform artifacts found in %s", dir)
}
if len(m.NativePackages) == 0 {
m.NativePackages = nil // omit empty map so older tooling sees a clean document
}
if len(m.Downloads) == 0 {
m.Downloads = nil
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
@@ -205,24 +268,35 @@ func genManifest(dir, version, tag string) error {
return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
}
// matchPlatform returns the platform key embedded in a file name, or "" if none.
func matchPlatform(name string) string {
// The .deb is a human-download package (like the macOS .dmg); the Linux updater
// channel is the .tar.gz. Skip it so it doesn't shadow the tarball's linux-amd64 key.
const (
artifactPortable = "portable"
artifactNative = "native"
)
// matchArtifact returns the platform key and channel kind embedded in a file name,
// or ("", "") if the file is not a publishable updater/download artifact.
func matchArtifact(name string) (key, kind string) {
// .deb is the Linux native package channel. Keep it out of platforms so the
// tarball remains the portable linux-amd64 key for older clients.
if strings.HasSuffix(name, ".deb") {
return ""
for _, p := range platforms {
if strings.Contains(name, p) {
return p, artifactNative
}
}
return "", ""
}
// The Windows updater channel is the per-arch -installer.exe; the portable .zip
// is a human download, so skip it or it would shadow the installer's key.
if strings.Contains(name, "windows-") && !strings.HasSuffix(name, "-installer.exe") {
return ""
return "", ""
}
for _, p := range platforms {
if strings.Contains(name, p) {
return p
return p, artifactPortable
}
}
return ""
return "", ""
}
// hashFile returns the size and lowercase-hex SHA-256 of a file, streaming it so
+86 -2
View File
@@ -55,6 +55,7 @@ func TestGenManifest(t *testing.T) {
names := []string{
"Reasonix-darwin-arm64.zip",
"Reasonix-darwin-amd64.zip",
"Reasonix-darwin-universal.dmg",
"Reasonix-windows-amd64-installer.exe",
"Reasonix-windows-amd64.zip", // portable download, not the updater channel
"Reasonix-windows-arm64-installer.exe",
@@ -114,8 +115,8 @@ func TestGenManifest(t *testing.T) {
if !strings.HasSuffix(arm.URL, "/Reasonix-windows-arm64-installer.exe") {
t.Fatalf("windows-arm64 url = %q, want the installer, not the portable zip", arm.URL)
}
// The Linux updater channel must stay the .tar.gz; the co-located .deb is a
// human download and must not shadow the linux-amd64 key.
// The Linux portable channel stays the .tar.gz; the co-located .deb lands
// only in native_packages so older clients keep resolving platforms["linux-amd64"].
lin, ok := m.Platforms["linux-amd64"]
if !ok {
t.Fatal("linux-amd64 missing")
@@ -123,4 +124,87 @@ func TestGenManifest(t *testing.T) {
if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") {
t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL)
}
if lin.Sig == "" || lin.SHA256 == "" || lin.Size == 0 {
t.Fatalf("linux portable asset incomplete: %+v", lin)
}
deb, ok := m.NativePackages["linux-amd64"]
if !ok {
t.Fatal("native_packages linux-amd64 missing")
}
if !strings.HasSuffix(deb.URL, "/Reasonix-linux-amd64.deb") {
t.Fatalf("native linux-amd64 url = %q, want the .deb", deb.URL)
}
if deb.Sig != deb.URL+".minisig" || deb.SHA256 == "" || deb.Size == 0 {
t.Fatalf("native linux asset incomplete: %+v", deb)
}
if len(m.Downloads) != 2 {
t.Fatalf("want 2 website downloads, got %d: %+v", len(m.Downloads), m.Downloads)
}
for _, name := range []string{"Reasonix-darwin-universal.dmg", "Reasonix-windows-amd64.zip"} {
asset, ok := m.Downloads[name]
if !ok {
t.Fatalf("website download %q missing", name)
}
if !strings.HasSuffix(asset.URL, "/"+name) ||
asset.Sig != asset.URL+".minisig" ||
asset.SHA256 == "" ||
asset.Size == 0 {
t.Fatalf("website download %q incomplete: %+v", name, asset)
}
}
}
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
// platform key is skipped rather than inventing a native_packages entry.
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
dir := t.TempDir()
for _, n := range []string{
"Reasonix-linux-amd64.tar.gz",
"Reasonix-mystery.deb",
} {
if err := os.WriteFile(filepath.Join(dir, n), []byte(n), 0o644); err != nil {
t.Fatal(err)
}
}
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := genManifest(dir, "v1.2.0", "desktop-v1.2.0"); err != nil {
t.Fatalf("genManifest: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
var m update.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.NativePackages) != 0 {
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
}
}
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
dir := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
if err != nil {
t.Fatal(err)
}
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
if err != nil {
t.Fatal(err)
}
for _, name := range update.WindowsPayloadFileNames() {
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
if hashes[name] != want {
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
}
}
}
+4 -4
View File
@@ -4,10 +4,10 @@ import "fmt"
func installerCommandLine(installer, dir string) string {
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
// The installer skips its welcome and directory pages in this mode, keeps
// the current install directory fixed, and closes itself after the file copy
// completes so this helper can relaunch Reasonix.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1`, installer)
// The current helper also requires staging-only extraction: NSIS never writes
// the live install, and the helper compare-and-publishes the claimed release
// unit after validating every staged member.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
if dir != "" {
line += " /D=" + dir
}
+4 -1
View File
@@ -7,7 +7,7 @@ import (
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /D=D:\Tools\Reasonix App`
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
@@ -17,4 +17,7 @@ func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
if !strings.Contains(got, " /REASONIXSTAGE=1") {
t.Fatalf("auto-update must extract away from the live install, got %q", got)
}
}
@@ -0,0 +1,10 @@
//go:build !windows
package main
import "os"
// Windows verifies each executable with the OS Authenticode policy while the
// same read handle is held. Non-Windows builds keep the release-unit loader
// testable without importing platform-specific trust APIs.
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
@@ -0,0 +1,97 @@
//go:build windows
package main
import (
"fmt"
"io"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
// reads the payload through one handle that disallows concurrent writes,
// deletes, and renames. The bytes handed to the publisher therefore cannot
// change between signature verification and publication.
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
path16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, fmt.Errorf("open staged payload handle")
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
if !fileInfo.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
trustFile := &windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
File: handle,
}
trustData := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_NONE,
UnionChoice: windows.WTD_CHOICE_FILE,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_EXECUTE,
}
verifyErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
if verifyErr != nil {
if closeErr != nil {
return nil, fmt.Errorf("Authenticode verification failed: %v (release trust state: %w)", verifyErr, closeErr)
}
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(file)
}
+489
View File
@@ -0,0 +1,489 @@
//go:build linux
// Command reasonix-update-helper (Linux) installs a verified .deb under Polkit.
// It is invoked only via pkexec with fixed argv and re-validates every input as
// root before calling apt-get. The unprivileged desktop process never runs apt.
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"
"reasonix/desktop/internal/update"
)
// Stable exit codes observed by the desktop installer.
const (
exitOK = 0
exitUsage = 2
exitNotRoot = 10
exitBadInput = 11
exitVerifyFailed = 12
exitPackageRejected = 13
exitBusy = 14
exitInstallFailed = 15
exitPostVerify = 16
)
const (
packageName = "reasonix-desktop"
dpkgDebPath = "/usr/bin/dpkg-deb"
dpkgQueryPath = "/usr/bin/dpkg-query"
dpkgPath = "/usr/bin/dpkg"
aptGetPath = "/usr/bin/apt-get"
// maxInputBytes bounds untrusted package/signature files before they are
// copied into the root temp directory (desktop .deb + minisig).
maxInputBytes = 512 << 20 // 512 MiB
// phasePrefix is a single-line protocol the desktop parses from stderr so
// the UI can leave "authorizing" once Polkit has launched this helper and
// validation finished, before apt-get starts.
phasePrefix = "REASONIX_UPDATE_PHASE="
)
type helperResult struct {
OK bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
}
// installDeps holds the privileged install seams. Production uses realDeps();
// tests inject fakes so every branch is deterministic without root/apt.
type installDeps struct {
geteuid func() int
getenv func(string) string
mkTempDir func() (string, error)
removeAll func(string) error
copyOwnedRegular func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error
readFile func(string) ([]byte, error)
verify func(data, sig []byte) error
inspectDeb func(path string) (debIdentity, error)
installedVersion func() (string, error)
compareVersions func(a, b string) (int, error)
aptInstall func(pkgPath string, allowDowngrade bool) error
verifyInstalled func(want string) error
writePhase func(phase string)
writeResult func(helperResult)
goArch string
maxInputBytes int64
}
func realDeps() installDeps {
return installDeps{
geteuid: os.Geteuid,
getenv: os.Getenv,
mkTempDir: func() (string, error) { return os.MkdirTemp("", "reasonix-update-*") },
removeAll: os.RemoveAll,
copyOwnedRegular: copyOwnedRegularFile,
readFile: os.ReadFile,
verify: update.Verify,
inspectDeb: inspectDeb,
installedVersion: installedPackageVersion,
compareVersions: compareDebVersions,
aptInstall: aptInstallOnlyUpgrade,
verifyInstalled: verifyInstalled,
writePhase: writePhaseLine,
writeResult: writeResultJSON,
goArch: runtime.GOARCH,
maxInputBytes: maxInputBytes,
}
}
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
return runWith(realDeps(), args)
}
func runWith(d installDeps, args []string) int {
if len(args) == 0 {
d.writeResult(helperResult{OK: false, Error: "missing command", Code: "usage"})
return exitUsage
}
switch args[0] {
case "install":
return runInstall(d, args[1:])
default:
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
return exitUsage
}
}
func runInstall(d installDeps, args []string) int {
fs := flag.NewFlagSet("install", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var packagePath, signaturePath string
fs.StringVar(&packagePath, "package", "", "path to the verified .deb")
fs.StringVar(&signaturePath, "signature", "", "path to the detached .minisig")
if err := fs.Parse(args); err != nil {
d.writeResult(helperResult{OK: false, Error: "invalid arguments", Code: "usage"})
return exitUsage
}
if packagePath == "" || signaturePath == "" || fs.NArg() != 0 {
d.writeResult(helperResult{OK: false, Error: "install requires --package and --signature", Code: "usage"})
return exitUsage
}
if d.geteuid() != 0 {
d.writeResult(helperResult{OK: false, Error: "helper must run as root", Code: "not_root"})
return exitNotRoot
}
pkUID, err := strconv.Atoi(strings.TrimSpace(d.getenv("PKEXEC_UID")))
if err != nil || pkUID < 0 {
d.writeResult(helperResult{OK: false, Error: "missing or invalid PKEXEC_UID", Code: "not_root"})
return exitNotRoot
}
tmpDir, err := d.mkTempDir()
if err != nil {
d.writeResult(helperResult{OK: false, Error: "create temp dir failed", Code: "install_failed"})
return exitInstallFailed
}
// Ensure root-only access before any untrusted bytes land here.
if err := os.Chmod(tmpDir, 0o700); err != nil {
_ = d.removeAll(tmpDir)
d.writeResult(helperResult{OK: false, Error: "secure temp dir failed", Code: "install_failed"})
return exitInstallFailed
}
defer func() { _ = d.removeAll(tmpDir) }()
maxBytes := d.maxInputBytes
if maxBytes <= 0 {
maxBytes = maxInputBytes
}
pkgCopy := filepath.Join(tmpDir, "package.deb")
sigCopy := filepath.Join(tmpDir, "package.deb.minisig")
if err := d.copyOwnedRegular(packagePath, pkgCopy, 0o600, pkUID, maxBytes); err != nil {
d.writeResult(helperResult{OK: false, Error: "invalid package input", Code: "bad_input"})
return exitBadInput
}
if err := d.copyOwnedRegular(signaturePath, sigCopy, 0o600, pkUID, maxBytes); err != nil {
d.writeResult(helperResult{OK: false, Error: "invalid signature input", Code: "bad_input"})
return exitBadInput
}
pkgData, err := d.readFile(pkgCopy)
if err != nil {
d.writeResult(helperResult{OK: false, Error: "read package failed", Code: "bad_input"})
return exitBadInput
}
sigData, err := d.readFile(sigCopy)
if err != nil {
d.writeResult(helperResult{OK: false, Error: "read signature failed", Code: "bad_input"})
return exitBadInput
}
// Re-verify as root; never trust the unprivileged process's prior check.
if err := d.verify(pkgData, sigData); err != nil {
d.writeResult(helperResult{OK: false, Error: "signature verification failed", Code: "verify_failed"})
return exitVerifyFailed
}
candidate, err := d.inspectDeb(pkgCopy)
if err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
if err := acceptDebIdentity(candidate, d.goArch); err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
installed, err := d.installedVersion()
if err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
cmp, err := d.compareVersions(candidate.Version, installed)
if err != nil {
d.writeResult(helperResult{OK: false, Error: "version compare failed", Code: "package_rejected"})
return exitPackageRejected
}
allowDowngrade, err := acceptVersionTransition(cmp, candidate.Version, installed)
if err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
// Polkit already authorized this process; validation is complete. Tell the
// desktop to leave "authorizing" before the long apt-get call.
d.writePhase("installing")
if err := d.aptInstall(pkgCopy, allowDowngrade); err != nil {
code := "install_failed"
exit := exitInstallFailed
if isPackageManagerBusy(err) {
code = "package_manager_busy"
exit = exitBusy
}
d.writeResult(helperResult{OK: false, Error: sanitizeHelperError(err), Code: code})
return exit
}
if err := d.verifyInstalled(candidate.Version); err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_verify_failed"})
return exitPostVerify
}
d.writeResult(helperResult{OK: true, Version: candidate.Version})
return exitOK
}
type debIdentity struct {
Package string
Version string
Arch string
}
// acceptDebIdentity enforces package name and architecture. Pure for tests.
func acceptDebIdentity(id debIdentity, goArch string) error {
if id.Package != packageName {
return errors.New("package name rejected")
}
wantArch := goArch
if wantArch == "386" {
wantArch = "i386"
}
if id.Arch != wantArch && id.Arch != "all" {
return errors.New("package architecture rejected")
}
if id.Version == "" {
return errors.New("package version missing")
}
return nil
}
var (
stableDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
previewDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)~preview\.(0|[1-9][0-9]*)$`)
)
// acceptVersionTransition permits ordinary upgrades and the one intentional
// downgrade: replacing a public Preview package with a public Stable package
// after the user switches channels.
func acceptVersionTransition(cmp int, candidate, installed string) (bool, error) {
switch {
case cmp > 0:
return false, nil
case cmp == 0:
return false, errors.New("candidate version is not strictly newer")
case stableDebVersionRE.MatchString(candidate) && previewDebVersionRE.MatchString(installed):
return true, nil
default:
return false, errors.New("candidate downgrade is not an allowed preview-to-stable transition")
}
}
// aptInstallArgv is the fixed absolute apt-get argv (never shell). Pure for tests.
func aptInstallArgv(pkgPath string, allowDowngrade bool) []string {
argv := []string{
aptGetPath,
"install",
"--assume-yes",
"--only-upgrade",
"--no-remove",
}
if allowDowngrade {
argv = append(argv, "--allow-downgrades")
}
return append(argv, pkgPath)
}
func inspectDeb(path string) (debIdentity, error) {
pkg, err := dpkgDebField(path, "Package")
if err != nil {
return debIdentity{}, errors.New("dpkg-deb inspection failed")
}
ver, err := dpkgDebField(path, "Version")
if err != nil {
return debIdentity{}, errors.New("dpkg-deb inspection failed")
}
arch, err := dpkgDebField(path, "Architecture")
if err != nil {
return debIdentity{}, errors.New("dpkg-deb inspection failed")
}
return debIdentity{Package: pkg, Version: ver, Arch: arch}, nil
}
func dpkgDebField(path, field string) (string, error) {
out, err := exec.Command(dpkgDebPath, "-f", path, field).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func installedPackageVersion() (string, error) {
out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Version}", packageName).Output()
if err != nil {
return "", errors.New("installed package not found")
}
v := strings.TrimSpace(string(out))
if v == "" {
return "", errors.New("installed package version empty")
}
return v, nil
}
// compareDebVersions returns >0 when a > b using dpkg --compare-versions.
func compareDebVersions(a, b string) (int, error) {
if err := exec.Command(dpkgPath, "--compare-versions", a, "gt", b).Run(); err == nil {
return 1, nil
}
if err := exec.Command(dpkgPath, "--compare-versions", a, "eq", b).Run(); err == nil {
return 0, nil
}
if err := exec.Command(dpkgPath, "--compare-versions", a, "lt", b).Run(); err == nil {
return -1, nil
}
return 0, errors.New("compare-versions failed")
}
func aptInstallOnlyUpgrade(pkgPath string, allowDowngrade bool) error {
argv := aptInstallArgv(pkgPath, allowDowngrade)
cmd := exec.Command(argv[0], argv[1:]...)
// Fixed absolute argv only — never shell.
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func verifyInstalled(wantVersion string) error {
out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Status}\n${Version}", packageName).Output()
if err != nil {
return errors.New("post-install dpkg-query failed")
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return errors.New("post-install package state incomplete")
}
if strings.TrimSpace(lines[0]) != "install ok installed" {
return errors.New("package not in install ok installed state")
}
if strings.TrimSpace(lines[1]) != wantVersion {
return errors.New("installed version mismatch")
}
return nil
}
// copyOwnedRegularFile opens src with O_NOFOLLOW only (fail closed — no Lstat/Open
// fallback), requires a regular file owned by ownerUID, enforces maxBytes, and
// writes a root-owned copy at dst. Ownership is checked on the opened fd.
func copyOwnedRegularFile(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
f, err := os.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
// Fail closed: never fall back to a followable open (TOCTOU).
return fmt.Errorf("open without following links: %w", err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("not a regular file")
}
if maxBytes > 0 && info.Size() > maxBytes {
return errors.New("input exceeds size bound")
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("stat owner unavailable")
}
if int(st.Uid) != ownerUID {
return errors.New("input owner does not match PKEXEC_UID")
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, mode)
if err != nil {
return err
}
defer out.Close()
// Cap the copy even if size grew after Stat (regular files can still be
// truncated/appended by the owner on some filesystems; bound the bytes we take).
limited := io.LimitReader(f, maxBytes+1)
n, err := io.Copy(out, limited)
if err != nil {
return err
}
if maxBytes > 0 && n > maxBytes {
return errors.New("input exceeds size bound")
}
return out.Close()
}
func isPackageManagerBusy(err error) bool {
if err == nil {
return false
}
low := strings.ToLower(err.Error())
return strings.Contains(low, "could not get lock") ||
strings.Contains(low, "unable to acquire the dpkg frontend lock") ||
strings.Contains(low, "is another process using it") ||
strings.Contains(low, "dpkg frontend lock")
}
// sanitizeHelperError strips absolute paths from helper diagnostics so the
// desktop UI never surfaces user home directories.
func sanitizeHelperError(err error) string {
if err == nil {
return "install failed"
}
msg := err.Error()
// Drop anything that looks like an absolute path segment.
fields := strings.Fields(msg)
for i, f := range fields {
if strings.HasPrefix(f, "/") {
fields[i] = "<path>"
}
}
out := strings.Join(fields, " ")
if out == "" {
return "install failed"
}
if len(out) > 240 {
out = out[:240]
}
return out
}
func writePhaseLine(phase string) {
// Single line, no user paths — desktop parses this while the helper runs.
fmt.Fprintf(os.Stderr, "%s%s\n", phasePrefix, phase)
}
func writeResultJSON(r helperResult) {
enc := json.NewEncoder(os.Stdout)
_ = enc.Encode(r)
}
// parsePhaseLine extracts a progress phase from a helper stderr line.
func parsePhaseLine(line string) (phase string, ok bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, phasePrefix) {
return "", false
}
phase = strings.TrimSpace(strings.TrimPrefix(line, phasePrefix))
return phase, phase != ""
}
@@ -0,0 +1,596 @@
//go:build linux
package main
import (
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"aead.dev/minisign"
)
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
err := errors.New("/home/user/cache/foo.deb locked by /var/lib/dpkg/lock")
got := sanitizeHelperError(err)
if strings.Contains(got, "/home/") || strings.Contains(got, "/var/") {
t.Fatalf("path leaked: %q", got)
}
if !strings.Contains(got, "<path>") {
t.Fatalf("expected path redaction, got %q", got)
}
}
func TestIsPackageManagerBusy(t *testing.T) {
if !isPackageManagerBusy(errors.New("Could not get lock /var/lib/dpkg/lock-frontend")) {
t.Fatal("expected busy detection")
}
if isPackageManagerBusy(errors.New("dependency problems prevent configuration")) {
t.Fatal("dependency failure is not busy")
}
}
func TestAcceptDebIdentity(t *testing.T) {
cases := []struct {
name string
id debIdentity
goArch string
wantErr string
}{
{
name: "ok amd64",
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "amd64"},
goArch: "amd64",
},
{
name: "wrong package",
id: debIdentity{Package: "evil", Version: "1.2.3", Arch: "amd64"},
goArch: "amd64",
wantErr: "package name rejected",
},
{
name: "wrong arch",
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "arm64"},
goArch: "amd64",
wantErr: "package architecture rejected",
},
{
name: "arch all allowed",
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "all"},
goArch: "amd64",
},
{
name: "missing version",
id: debIdentity{Package: packageName, Version: "", Arch: "amd64"},
goArch: "amd64",
wantErr: "package version missing",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := acceptDebIdentity(tc.id, tc.goArch)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
return
}
if err == nil || err.Error() != tc.wantErr {
t.Fatalf("err = %v, want %q", err, tc.wantErr)
}
})
}
}
func TestAcceptVersionTransition(t *testing.T) {
tests := []struct {
name string
cmp int
candidate string
installed string
wantDowngrade bool
wantErr bool
}{
{name: "ordinary upgrade", cmp: 1, candidate: "1.3.0", installed: "1.2.0"},
{name: "equal rejected", cmp: 0, candidate: "1.2.0", installed: "1.2.0", wantErr: true},
{name: "preview to older stable", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1", wantDowngrade: true},
{name: "stable to older stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0", wantErr: true},
{name: "preview to older preview rejected", cmp: -1, candidate: "1.2.0~preview.1", installed: "1.3.0~preview.1", wantErr: true},
{name: "rc to stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~rc.1", wantErr: true},
{name: "internal preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1+internal", wantErr: true},
{name: "malformed stable rejected", cmp: -1, candidate: "01.2.0", installed: "1.3.0~preview.1", wantErr: true},
{name: "malformed preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.01", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowDowngrade, err := acceptVersionTransition(tt.cmp, tt.candidate, tt.installed)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if allowDowngrade != tt.wantDowngrade {
t.Fatalf("allowDowngrade = %v, want %v", allowDowngrade, tt.wantDowngrade)
}
})
}
}
func TestAptInstallArgvFixed(t *testing.T) {
argv := aptInstallArgv("/tmp/pkg.deb", false)
want := []string{
"/usr/bin/apt-get",
"install",
"--assume-yes",
"--only-upgrade",
"--no-remove",
"/tmp/pkg.deb",
}
if len(argv) != len(want) {
t.Fatalf("argv = %v", argv)
}
for i := range want {
if argv[i] != want[i] {
t.Fatalf("argv[%d] = %q, want %q", i, argv[i], want[i])
}
}
joined := strings.Join(argv, " ")
for _, bad := range []string{"--allow-unauthenticated", "--allow-downgrades", "--force-yes", "sh -c"} {
if strings.Contains(joined, bad) {
t.Fatalf("forbidden apt option present: %s in %v", bad, argv)
}
}
downgradeArgv := aptInstallArgv("/tmp/pkg.deb", true)
if strings.Count(strings.Join(downgradeArgv, " "), "--allow-downgrades") != 1 {
t.Fatalf("constrained downgrade argv = %v", downgradeArgv)
}
if downgradeArgv[len(downgradeArgv)-1] != "/tmp/pkg.deb" {
t.Fatalf("package path must remain the final fixed argument: %v", downgradeArgv)
}
}
func TestParsePhaseLine(t *testing.T) {
phase, ok := parsePhaseLine(phasePrefix + "installing")
if !ok || phase != "installing" {
t.Fatalf("got %q %v", phase, ok)
}
if _, ok := parsePhaseLine("noise"); ok {
t.Fatal("noise should not parse")
}
}
func TestCopyOwnedRegularFileRejectsSymlinkFailClosed(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "real.deb")
if err := os.WriteFile(target, []byte("deb"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link.deb")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
// Any UID — O_NOFOLLOW must fail before ownership is checked.
if err := copyOwnedRegularFile(link, dst, 0o600, os.Getuid(), maxInputBytes); err == nil {
t.Fatal("symlink must be rejected without fallback open")
}
}
func TestCopyOwnedRegularFileRejectsWrongOwner(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root; cannot construct a non-self-owned file cheaply")
}
dir := t.TempDir()
src := filepath.Join(dir, "in.deb")
if err := os.WriteFile(src, []byte("package-bytes"), 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
// Claim the file must be owned by root while the test user owns it.
if err := copyOwnedRegularFile(src, dst, 0o600, 0, maxInputBytes); err == nil {
t.Fatal("wrong owner must be rejected")
}
}
func TestCopyOwnedRegularFileRejectsOversize(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.deb")
if err := os.WriteFile(src, []byte("0123456789"), 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), 4); err == nil {
t.Fatal("oversize input must be rejected")
}
}
func TestCopyOwnedRegularFileCopiesMatchingOwner(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.deb")
payload := []byte("package-bytes")
if err := os.WriteFile(src, payload, 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), maxInputBytes); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("copy mismatch: %q", got)
}
}
// --- runInstall table-driven coverage via injectable deps ---
type installProbe struct {
phases []string
results []helperResult
aptCalls []string
}
func baseFakeDeps(p *installProbe) installDeps {
return installDeps{
geteuid: func() int { return 0 },
getenv: func(k string) string {
if k == "PKEXEC_UID" {
return "1000"
}
return ""
},
mkTempDir: func() (string, error) {
return os.MkdirTemp("", "reasonix-helper-test-*")
},
removeAll: os.RemoveAll,
copyOwnedRegular: func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, mode)
},
readFile: os.ReadFile,
verify: func(data, sig []byte) error { return nil },
inspectDeb: func(path string) (debIdentity, error) {
return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
},
installedVersion: func() (string, error) { return "1.1.0", nil },
compareVersions: func(a, b string) (int, error) { return 1, nil },
aptInstall: func(pkgPath string, allowDowngrade bool) error {
p.aptCalls = append(p.aptCalls, pkgPath)
// Assert argv shape through the pure helper used by production.
argv := aptInstallArgv(pkgPath, allowDowngrade)
if argv[0] != aptGetPath || argv[1] != "install" {
return fmt.Errorf("bad argv: %v", argv)
}
return nil
},
verifyInstalled: func(want string) error {
if want != "1.2.0" {
return errors.New("installed version mismatch")
}
return nil
},
writePhase: func(phase string) { p.phases = append(p.phases, phase) },
writeResult: func(r helperResult) { p.results = append(p.results, r) },
goArch: "amd64",
maxInputBytes: maxInputBytes,
}
}
func writeInstallInputs(t *testing.T) (pkg, sig string) {
t.Helper()
dir := t.TempDir()
pkg = filepath.Join(dir, "Reasonix.deb")
sig = filepath.Join(dir, "Reasonix.deb.minisig")
if err := os.WriteFile(pkg, []byte("deb-payload"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sig, []byte("sig-payload"), 0o600); err != nil {
t.Fatal(err)
}
return pkg, sig
}
func TestRunInstallSuccessEmitsInstallingPhaseBeforeApt(t *testing.T) {
pkg, sig := writeInstallInputs(t)
var probe installProbe
d := baseFakeDeps(&probe)
aptStarted := false
d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
aptStarted = true
if allowDowngrade {
t.Fatal("ordinary upgrade unexpectedly enabled downgrades")
}
if len(probe.phases) == 0 || probe.phases[0] != "installing" {
t.Fatalf("phase installing must be emitted before apt; phases=%v", probe.phases)
}
probe.aptCalls = append(probe.aptCalls, pkgPath)
return nil
}
code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
if code != exitOK {
t.Fatalf("exit = %d, results=%+v", code, probe.results)
}
if !aptStarted || len(probe.aptCalls) != 1 {
t.Fatalf("apt not called: %+v", probe.aptCalls)
}
if len(probe.results) != 1 || !probe.results[0].OK || probe.results[0].Version != "1.2.0" {
t.Fatalf("result = %+v", probe.results)
}
}
func TestRunInstallTable(t *testing.T) {
pkg, sig := writeInstallInputs(t)
type tc struct {
name string
mutate func(*installDeps, *installProbe)
args []string
wantCode int
wantCodeStr string
wantPhase bool // installing emitted only on success path past validation
wantOK bool
}
cases := []tc{
{
name: "usage missing flags",
args: []string{"install"},
wantCode: exitUsage,
wantCodeStr: "usage",
},
{
name: "not root",
mutate: func(d *installDeps, _ *installProbe) {
d.geteuid = func() int { return 1000 }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitNotRoot,
wantCodeStr: "not_root",
},
{
name: "missing PKEXEC_UID",
mutate: func(d *installDeps, _ *installProbe) {
d.getenv = func(string) string { return "" }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitNotRoot,
wantCodeStr: "not_root",
},
{
name: "bad input copy",
mutate: func(d *installDeps, _ *installProbe) {
d.copyOwnedRegular = func(string, string, os.FileMode, int, int64) error {
return errors.New("owner mismatch")
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitBadInput,
wantCodeStr: "bad_input",
},
{
name: "verify failed",
mutate: func(d *installDeps, _ *installProbe) {
d.verify = func([]byte, []byte) error { return errors.New("bad sig") }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitVerifyFailed,
wantCodeStr: "verify_failed",
},
{
name: "wrong package name",
mutate: func(d *installDeps, _ *installProbe) {
d.inspectDeb = func(string) (debIdentity, error) {
return debIdentity{Package: "other", Version: "2.0", Arch: "amd64"}, nil
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "wrong architecture",
mutate: func(d *installDeps, _ *installProbe) {
d.inspectDeb = func(string) (debIdentity, error) {
return debIdentity{Package: packageName, Version: "2.0", Arch: "arm64"}, nil
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "same version rejected",
mutate: func(d *installDeps, _ *installProbe) {
d.compareVersions = func(a, b string) (int, error) { return 0, nil }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "downgrade rejected",
mutate: func(d *installDeps, _ *installProbe) {
d.compareVersions = func(a, b string) (int, error) { return -1, nil }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "preview to stable downgrade allowed",
mutate: func(d *installDeps, p *installProbe) {
d.inspectDeb = func(string) (debIdentity, error) {
return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
}
d.installedVersion = func() (string, error) { return "1.3.0~preview.1", nil }
d.compareVersions = func(a, b string) (int, error) { return -1, nil }
d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
if !allowDowngrade {
return errors.New("preview-to-stable downgrade flag missing")
}
p.aptCalls = append(p.aptCalls, pkgPath)
return nil
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitOK,
wantPhase: true,
wantOK: true,
},
{
name: "apt busy",
mutate: func(d *installDeps, _ *installProbe) {
d.aptInstall = func(string, bool) error {
return errors.New("Could not get lock /var/lib/dpkg/lock-frontend")
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitBusy,
wantCodeStr: "package_manager_busy",
wantPhase: true,
},
{
name: "apt non-zero",
mutate: func(d *installDeps, _ *installProbe) {
d.aptInstall = func(string, bool) error {
return errors.New("E: Sub-process /usr/bin/dpkg returned an error code (1)")
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitInstallFailed,
wantCodeStr: "install_failed",
wantPhase: true,
},
{
name: "post install version mismatch",
mutate: func(d *installDeps, _ *installProbe) {
d.verifyInstalled = func(string) error { return errors.New("installed version mismatch") }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPostVerify,
wantCodeStr: "package_verify_failed",
wantPhase: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var probe installProbe
d := baseFakeDeps(&probe)
if tc.mutate != nil {
tc.mutate(&d, &probe)
}
code := runWith(d, tc.args)
if code != tc.wantCode {
t.Fatalf("exit = %d, want %d; results=%+v", code, tc.wantCode, probe.results)
}
if len(probe.results) == 0 {
t.Fatal("expected a written result")
}
last := probe.results[len(probe.results)-1]
if last.OK != tc.wantOK {
t.Fatalf("result OK = %v, want %v: %+v", last.OK, tc.wantOK, last)
}
if tc.wantCodeStr != "" && last.Code != tc.wantCodeStr {
t.Fatalf("code = %q, want %q", last.Code, tc.wantCodeStr)
}
if tc.wantPhase {
if len(probe.phases) == 0 || probe.phases[0] != "installing" {
t.Fatalf("expected installing phase before failure after validation, got %v", probe.phases)
}
} else if len(probe.phases) > 0 {
// Validation failures must not claim apt started.
t.Fatalf("unexpected phase before validation failure: %v", probe.phases)
}
})
}
}
func TestRunInstallCleansTempDirOnFailure(t *testing.T) {
pkg, sig := writeInstallInputs(t)
var probe installProbe
d := baseFakeDeps(&probe)
var created, removed string
d.mkTempDir = func() (string, error) {
dir, err := os.MkdirTemp("", "reasonix-helper-test-*")
created = dir
return dir, err
}
d.removeAll = func(path string) error {
removed = path
return os.RemoveAll(path)
}
d.verify = func([]byte, []byte) error { return errors.New("fail after tmp") }
code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
if code != exitVerifyFailed {
t.Fatalf("exit = %d", code)
}
if created == "" || removed != created {
t.Fatalf("temp dir not cleaned: created=%q removed=%q", created, removed)
}
if _, err := os.Stat(created); !os.IsNotExist(err) {
t.Fatalf("temp dir still on disk: %v", err)
}
}
func TestRunInstallRejectsUnknownCommand(t *testing.T) {
var probe installProbe
d := baseFakeDeps(&probe)
code := runWith(d, []string{"upgrade"})
if code != exitUsage {
t.Fatalf("exit = %d", code)
}
}
// TestMinisignRejectsWrongKeyAndTamper pins that the shared verify path used by
// the helper rejects throwaway-key signatures and tampered payloads. Production
// uses update.Verify with the embedded key; here we exercise the same minisign
// library contract the helper depends on.
func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
pub, priv, err := minisign.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
data := []byte("artifact")
sig := minisign.Sign(priv, data)
if !minisign.Verify(pub, data, sig) {
t.Fatal("genuine signature under matching key must verify")
}
if minisign.Verify(pub, []byte("tampered"), sig) {
t.Fatal("tampered payload must not verify")
}
otherPub, _, err := minisign.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
if minisign.Verify(otherPub, data, sig) {
t.Fatal("signature under a different public key must not verify")
}
// Garbage signature must fail under the matching public key.
if minisign.Verify(pub, data, []byte("not-a-sig")) {
t.Fatal("garbage signature must not verify")
}
// Wire the same contract into installDeps.verify so a wrong-key path fails
// runInstall with exitVerifyFailed.
var probe installProbe
d := baseFakeDeps(&probe)
d.verify = func(payload, signature []byte) error {
if !minisign.Verify(pub, payload, signature) {
return errors.New("signature verification failed")
}
return nil
}
pkg, sigPath := writeInstallInputs(t)
// Input files contain "deb-payload"/"sig-payload", not a real signature.
code := runWith(d, []string{"install", "--package", pkg, "--signature", sigPath})
if code != exitVerifyFailed {
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package main
@@ -8,6 +8,6 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
os.Exit(2)
}
+346 -16
View File
@@ -3,12 +3,17 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -19,20 +24,35 @@ import (
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installDir, relaunch, toVersion string
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -41,35 +61,129 @@ func run(args []string) int {
logger.Print("missing --installer")
return 2
}
if !validSHA256(installerSHA256) {
logger.Print("missing or invalid --installer-sha256")
return 2
}
if toVersion == "" {
logger.Print("missing --to-version")
return 2
}
if createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
if installDir == "" {
logger.Print("missing --install-dir")
return 2
}
if parentPID != 0 {
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if err := runInstaller(installer, installDir); err != nil {
logger.Printf("run installer: %v", err)
// The desktop already exited cleanly, so nothing would notice this
// failure: record it and relaunch through Guard, which rolls the
// release unit back on startup (the helper itself runs from the cache
// directory, outside the validated install, and must not restore
// binaries directly).
if markErr := repair.MarkUpdateApplyFailed(toVersion, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
claimed, releaseClaim, err := claimPendingFileUpdateFn(
toVersion,
createdAt,
transactionID,
filepath.Join(installDir, "reasonix-desktop.exe"),
windowsReleaseUnitPaths(installDir),
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return 1
}
defer releaseClaim()
recoverUnstarted := func() int {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunch(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install: %v", relaunchErr)
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
}
}
return 1
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind update installer: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create update staging: %v", err)
return recoverUnstarted()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind update staging: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged installer: %v", err)
return recoverUnstarted()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract installer payload: %v", installerErr)
return recoverUnstarted()
}
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
if err != nil {
logger.Printf("publish staged release unit: %v", err)
if !publishStarted {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
if relaunch != "" {
if err := startRelaunch(relaunch, installDir); err != nil {
if err := startRelaunchFn(relaunch, installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -77,6 +191,179 @@ func run(args []string) int {
return 0
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
if !validSHA256(expectedSHA256) {
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
}
sourceInfo, err := os.Lstat(sourcePath)
if err != nil {
return "", nil, err
}
if !sourceInfo.Mode().IsRegular() {
return "", nil, fmt.Errorf("installer is not a regular file")
}
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
if err != nil {
return "", nil, err
}
owner, err := os.Lstat(dir)
if err != nil {
return "", nil, err
}
cleanup := func() error {
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
}
fail := func(err error) (string, func() error, error) {
_ = cleanup()
return "", nil, err
}
source, err := os.Open(sourcePath)
if err != nil {
return fail(err)
}
defer source.Close()
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
if err != nil {
return fail(err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
syncErr := staged.Sync()
closeErr := staged.Close()
switch {
case copyErr != nil:
return fail(copyErr)
case syncErr != nil:
return fail(syncErr)
case closeErr != nil:
return fail(closeErr)
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
}
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
return fail(err)
}
return stagedPath, cleanup, nil
}
func verifyInstallerSHA256(path, expectedSHA256 string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("installer is not a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hash := sha256.New()
if _, err := io.Copy(hash, f); err != nil {
return err
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fmt.Errorf("installer SHA-256 mismatch")
}
return nil
}
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
if !validSHA256(expectedSHA256) {
return nil, fmt.Errorf("installer SHA-256 is invalid")
}
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathUTF16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
fail := func(err error) (func(), error) {
_ = file.Close()
return nil, err
}
info, err := file.Stat()
if err != nil {
return fail(err)
}
if !info.Mode().IsRegular() {
return fail(fmt.Errorf("installer is not a regular file"))
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fail(err)
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fail(fmt.Errorf("installer SHA-256 mismatch"))
}
var once sync.Once
return func() {
once.Do(func() { _ = file.Close() })
}, nil
}
func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
if err != nil {
return false, nil, err
}
// Persist recovery intent before the first live rename. If the helper exits
// between release-unit members, Guard rolls the exact pending transaction
// back instead of launching a mixed install.
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
}
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
if err != nil {
return true, receipts, err
}
return true, receipts, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
if installDir == "" {
return nil
}
names := []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
"Reasonix.exe",
}
paths := make([]string, 0, len(names))
for _, name := range names {
paths = append(paths, filepath.Join(installDir, name))
}
return paths
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
@@ -117,12 +404,55 @@ func waitForProcessExit(pid uint32, timeout time.Duration) error {
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
// Keep the helper itself hidden, but let the NSIS update-progress window be
// visible. The dedicated /REASONIXUPDATE mode prevents directory changes and
// closes automatically after the update finishes.
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
// performs every live replacement through the claimed transaction.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
return cmd.Run()
}
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
if path == "" || owner == nil || !owner.IsDir() {
return fmt.Errorf("Windows update cleanup identity is incomplete")
}
for attempt := 0; attempt < 16; attempt++ {
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
from, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(cleanup)
if err != nil {
return err
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
if os.IsNotExist(err) {
return nil
}
if os.IsExist(err) {
continue
}
return err
}
actual, err := os.Lstat(cleanup)
if err != nil {
return err
}
if !os.SameFile(owner, actual) {
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
restoreTo, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
}
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
}
return fmt.Errorf("Windows update staging changed before cleanup")
}
return os.RemoveAll(cleanup)
}
return fmt.Errorf("cannot allocate Windows update cleanup path")
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
+419 -2
View File
@@ -2,10 +2,427 @@
package main
import "testing"
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/repair"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(source, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
expected := hex.EncodeToString(sum[:])
staged, cleanup, err := stageVerifiedInstaller(source, expected)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cleanup() })
if staged == source {
t.Fatal("installer was not isolated from the mutable cache path")
}
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err != nil {
t.Fatalf("isolated installer changed with source: %v", err)
}
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
t.Fatalf("staged installer = %q, %v", got, err)
}
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err == nil {
t.Fatal("tampered staged installer passed final verification")
}
}
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
t.Fatal(err)
}
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
if cleanup != nil {
_ = cleanup()
}
t.Fatal("installer bytes different from desktop verification were accepted")
}
}
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
if code := run([]string{"--installer", `C:\Temp\Reasonix-installer.exe`}); code != 2 {
if code := run([]string{
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", testInstallerSHA256,
}); code != 2 {
t.Fatalf("run without --to-version = %d, want 2", code)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
installDir := t.TempDir()
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error {
events = append(events, "wait")
return nil
}
runInstallerFn = func(string, string) error {
events = append(events, "installer")
return nil
}
startRelaunchFn = func(string, string) error {
events = append(events, "relaunch")
return nil
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, nil, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
return tx, nil
}
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
events = append(events, "stage-installer:"+sha256)
return path + ".claimed", func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
events = append(events, "claim-installer")
return func() { events = append(events, "release-installer") }, nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
"v2",
"2026-07-29T00:00:00Z",
"transaction-1",
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
events[2] != "stage-installer:"+testInstallerSHA256 ||
events[3] != "claim-installer" || events[4] != "installer" ||
events[5] != "release-installer" || events[6] != "publish" ||
events[7] != "record-installed" || events[8] != "relaunch" ||
events[9] != "release" {
t.Fatalf("handoff events = %#v", events)
}
}
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
})
waitForProcessExitFn = func(uint32, time.Duration) error {
return errors.New("parent still running")
}
claimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
claimed = true
return &repair.UpdateTransaction{}, func() {}, nil
}
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if claimed || installerRan {
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
const createdAt = "2026-07-29T00:00:00Z"
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", createdAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if _, ok := repair.ReadUpdateApplyFailure(); ok {
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
}
}
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return nil }
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, nil, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
}
failure, ok := repair.ReadUpdateApplyFailure()
if !ok || failure.UpdateTransactionID != transactionID {
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
}
}
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalLstatStaging := lstatUpdateStagingFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
lstatUpdateStagingFn = originalLstatStaging
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
return nil, errors.New("staging identity unavailable")
}
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", claimed.ToVersion,
"--created-at", claimed.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(claimed),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if installerRan {
t.Fatal("installer ran without a bound staging directory")
}
if !relaunched {
t.Fatal("helper did not relaunch after staging identity failure")
}
if releases == 0 {
t.Fatal("helper did not release the update claim")
}
}
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(path, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
release()
t.Fatal("installer remained writable while execution claim was held")
}
if err := os.Rename(path, path+".replaced"); err == nil {
release()
t.Fatal("installer remained renameable while execution claim was held")
}
release()
release()
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
t.Fatalf("installer remained frozen after claim release: %v", err)
}
}
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
path := filepath.Join(t.TempDir(), "installer.exe")
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
release()
t.Fatal("installer with the wrong SHA-256 received an execution claim")
}
}
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/desktop/internal/update"
"reasonix/internal/repair"
)
const maxWindowsPayloadMetadataSize = 64 << 10
var verifyWindowsPayloadManifestFn = update.Verify
type stagedFileUpdateMember struct {
targetPath string
content []byte
mode os.FileMode
}
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
// before any live release-unit member is moved. An existing Reasonix.exe is the
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
// installed package that did not have the alias remains unchanged.
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
}
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
}
info, err := os.Lstat(stagingDir)
if err != nil {
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
}
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
if err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
contents := make(map[string][]byte)
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
seenTargets := make(map[string]struct{}, len(claimed.Files))
for _, file := range claimed.Files {
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
targetKey := strings.ToLower(targetPath)
if targetPath == "" || targetPath == "." {
return nil, fmt.Errorf("load staged release unit: target path is invalid")
}
if _, ok := seenTargets[targetKey]; ok {
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
}
seenTargets[targetKey] = struct{}{}
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
continue
}
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
if err != nil {
return nil, err
}
sourcePath := filepath.Join(stagingDir, sourceName)
content, ok := contents[sourceName]
if !ok {
sourceInfo, statErr := os.Lstat(sourcePath)
if statErr != nil {
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
}
if !sourceInfo.Mode().IsRegular() {
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
}
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
if err != nil {
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
}
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
}
contents[sourceName] = content
}
members = append(members, stagedFileUpdateMember{
targetPath: targetPath,
content: content,
mode: 0o700,
})
}
// Publish the running desktop last. If an earlier member fails, the old
// desktop remains the executable entry point that can report/retry recovery.
sort.SliceStable(members, func(i, j int) bool {
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
return !iPrimary && jPrimary
})
return members, nil
}
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
if claimed == nil ||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
return fmt.Errorf("claimed release unit primary executable is invalid")
}
required := map[string]bool{
"reasonix-desktop.exe": false,
"reasonix-guard.exe": false,
"reasonix-launcher.exe": false,
"reasonix-update-helper.exe": false,
"reasonix-cli.exe": false,
"reasonix.exe": false,
}
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
for _, file := range claimed.Files {
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
if target == "" || target == "." ||
!strings.EqualFold(filepath.Dir(target), installDir) {
return fmt.Errorf("claimed release unit target is outside the installation directory")
}
name := strings.ToLower(filepath.Base(target))
seen, ok := required[name]
if !ok {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
if seen {
return fmt.Errorf("claimed release unit contains a duplicate target")
}
required[name] = true
}
for name, seen := range required {
if !seen {
return fmt.Errorf("claimed release unit omits %s", name)
}
}
if len(claimed.Files) != len(required) {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
return nil
}
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest: %w", err)
}
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
}
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
return nil, fmt.Errorf("verify signed release manifest: %w", err)
}
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
if err != nil {
return nil, err
}
return hashes, nil
}
func readWindowsPayloadMetadata(path string) ([]byte, error) {
pathInfo, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !pathInfo.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
}
if !os.SameFile(pathInfo, info) {
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
}
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
if err != nil {
return nil, err
}
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
}
return data, nil
}
func windowsStagedSourceName(targetBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(targetBase)) {
case "reasonix-desktop.exe":
return "reasonix-desktop.exe", nil
case "reasonix-guard.exe":
return "reasonix-guard.exe", nil
case "reasonix-launcher.exe":
return "reasonix-launcher.exe", nil
case "reasonix-update-helper.exe":
return "reasonix-update-helper.exe", nil
case "reasonix-cli.exe":
return "reasonix-cli.exe", nil
case "reasonix.exe":
return "reasonix-launcher.exe", nil
default:
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
}
}
func publishLoadedFileUpdateReleaseUnit(
claimed *repair.UpdateTransaction,
members []stagedFileUpdateMember,
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
) ([]repair.FileUpdateInstallReceipt, error) {
if publish == nil || len(members) == 0 {
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
}
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
for _, member := range members {
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
if err != nil {
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
}
receipts = append(receipts, receipt)
}
return receipts, nil
}
@@ -0,0 +1,376 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/desktop/internal/update"
"reasonix/internal/repair"
)
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
staging := t.TempDir()
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := t.TempDir()
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
t.Fatalf("last published member = %q, want desktop", got)
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, filepath.Base(target)+"="+string(content))
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
})
if err != nil {
t.Fatal(err)
}
if len(receipts) != len(members) {
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
}
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
}
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
t.Fatalf("publish order = %v", published)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
staging := t.TempDir()
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
t.Fatal(err)
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := t.TempDir()
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
},
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("incomplete staged release unit was accepted")
}
}
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
for i, file := range complete.Files {
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "omits") {
t.Fatalf("missing target error = %v", err)
}
})
}
t.Run("extra", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "unexpected") {
t.Fatalf("extra target error = %v", err)
}
})
t.Run("duplicate", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate target error = %v", err)
}
})
t.Run("outside", func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
claimed.Files[1].TargetPath = filepath.Join(t.TempDir(), filepath.Base(claimed.Files[1].TargetPath))
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "outside") {
t.Fatalf("outside target error = %v", err)
}
})
t.Run("primary", func(t *testing.T) {
claimed := *complete
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "primary") {
t.Fatalf("primary target error = %v", err)
}
})
}
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
staging := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
content := "payload:" + name
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := t.TempDir()
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
for _, member := range members {
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
t.Fatalf("missing portable alias was added to publish set: %+v", members)
}
}
}
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
claimed := &repair.UpdateTransaction{TargetKind: "file"}
members := []stagedFileUpdateMember{
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, target)
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
})
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
t.Fatalf("publish error = %v", err)
}
if len(published) != 1 {
t.Fatalf("published members = %v, want one attempted member", published)
}
if len(receipts) != 0 {
t.Fatalf("failed first publish returned receipts: %+v", receipts)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
return nil, errors.New("payload signature rejected")
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("unsigned staged payload must be rejected before publication")
}
}
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
staging := t.TempDir()
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
var verified []string
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
verified = append(verified, filepath.Base(path))
return os.ReadFile(path)
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
t.Fatal(err)
}
if len(verified) != len(update.WindowsPayloadFileNames()) {
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
t.Fatalf("missing signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "verify signed release manifest") {
t.Fatalf("bad signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v3")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("release version drift error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
t.Fatalf("release member drift error = %v", err)
}
}
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
t.Helper()
staging := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
installDir := t.TempDir()
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
for _, name := range update.WindowsPayloadFileNames() {
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
}
files = append(files, repair.UpdateTransactionFile{
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
MissingBefore: true,
})
return staging, &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: version,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: files,
}
}
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
t.Helper()
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
content, err := os.ReadFile(filepath.Join(staging, name))
if os.IsNotExist(err) {
content = []byte("missing:" + name)
} else if err != nil {
t.Fatal(err)
}
hashes[name] = update.WindowsPayloadSHA256(content)
}
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
t.Fatal(err)
}
}
func acceptWindowsPayloadManifestForTest(t *testing.T) {
t.Helper()
original := verifyWindowsPayloadManifestFn
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
if string(signature) != "test signature" {
return errors.New("unexpected test signature")
}
return nil
}
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
}
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
t.Helper()
writeWindowsPayloadManifestForTest(t, staging, version)
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
}
func useUnverifiedPayloadReaderForTest(t *testing.T) {
t.Helper()
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = os.ReadFile
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
}
+3 -2
View File
@@ -11,8 +11,9 @@ import (
"strings"
)
// crash_app.go is the crash/feedback/performance reporting surface. Reports are
// sent only on an explicit user click in the frontend UI — never automatically.
// crash_app.go is the crash/feedback/performance reporting surface. Frontend
// reports are sent on an explicit user click. Native fatal/lifecycle reports are
// queued locally and sent on a later launch only when desktop telemetry is on.
var crashEndpoint = "https://crash.reasonix.io/v1/report"
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/repair"
)
func TestParseWebView2ProcessFailure(t *testing.T) {
kind, ok := parseWebView2ProcessFailure("windows | WebVie2wProcess failed with kind 6")
if !ok || kind != 6 {
t.Fatalf("kind=%d ok=%v", kind, ok)
}
if _, ok := parseWebView2ProcessFailure("unrelated failure"); ok {
t.Fatal("unrelated log message matched WebView2 failure")
}
}
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
report := webView2ProcessFailureReport(2)
if report.Source != "webview2.process" || report.Label != "windows.webview2.process_failed" {
t.Fatalf("report = %+v", report)
}
if report.FingerprintHint != "windows.webview2.render_process_unresponsive" {
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
}
}
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
report := previousRunReport(repair.PreviousRunObservation{
Abnormal: true,
Phase: "healthy",
Version: "v2",
InstallProfile: "installer",
UpdateFrom: "v1",
UpdateTo: "v2",
UptimeBucket: "m_2_10",
})
if report.Source != "native.lifecycle" || report.Label != "desktop.abnormal_exit" {
t.Fatalf("report = %+v", report)
}
if !strings.Contains(report.Message, "uptime bucket: m_2_10") {
t.Fatalf("message missing bounded uptime: %q", report.Message)
}
}
func TestCapturePreviousFatalCrashQueuesAndRemovesRawDump(t *testing.T) {
resetFatalCrashArtifacts(t)
const crashedPID = 424242
raw := "fatal error: concurrent map writes\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
path := fatalCrashPathForPID(crashedPID)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("raw fatal dump was not removed: %v", err)
}
report, ok := readPending(t)
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
t.Fatalf("queued report = %+v ok=%v", report, ok)
}
if strings.Contains(report.Stack, "/home/alice") {
t.Fatalf("fatal stack leaked home path: %q", report.Stack)
}
}
func TestSanitizeFatalRuntimeDumpRemovesPanicValue(t *testing.T) {
got := sanitizeFatalRuntimeDump("panic: private prompt text\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n")
if strings.Contains(got, "private prompt text") || strings.Contains(got, "/home/alice") {
t.Fatalf("fatal dump leaked user-controlled text: %q", got)
}
if !strings.Contains(got, "panic: [redacted panic value]") || !strings.Contains(got, "main.go:12") {
t.Fatalf("fatal dump lost diagnostic structure: %q", got)
}
}
func TestCapturePreviousFatalCrashSkipsRuntimeDuplicateOfStructuredPanic(t *testing.T) {
resetFatalCrashArtifacts(t)
const crashedPID = 424243
path := fatalCrashPathForPID(crashedPID)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("panic: duplicate\n\ngoroutine 1 [running]:\nmain.run()\n"), 0o600); err != nil {
t.Fatal(err)
}
markFatalCrashCoveredForPID(crashedPID)
capturePreviousFatalCrash()
if _, ok := readPending(t); ok {
t.Fatal("runtime duplicate was queued despite structured panic marker")
}
}
func TestCapturePreviousFatalCrashDoesNotTouchLiveOwner(t *testing.T) {
resetFatalCrashArtifacts(t)
const livePID = 424244
path := fatalCrashPathForPID(livePID)
raw := []byte("fatal error: still being written\n\ngoroutine 1 [running]:\n")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
oldProcessAlive := fatalCrashProcessAlive
fatalCrashProcessAlive = func(pid int) bool { return pid == livePID }
t.Cleanup(func() { fatalCrashProcessAlive = oldProcessAlive })
capturePreviousFatalCrash()
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live owner's fatal file was removed: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("live owner's fatal file changed: got %q want %q", got, raw)
}
if _, ok := readPending(t); ok {
t.Fatal("live owner's partial fatal output was queued")
}
}
func TestCapturePreviousFatalCrashKeepsEmptyLegacyFile(t *testing.T) {
resetFatalCrashArtifacts(t)
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyFatalCrashPath(), nil, 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(legacyFatalCrashPath()); err != nil {
t.Fatalf("empty legacy fatal file may belong to a live older process: %v", err)
}
}
func TestCapturePreviousFatalCrashMigratesLegacyDump(t *testing.T) {
resetFatalCrashArtifacts(t)
raw := "fatal error: legacy crash\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyFatalCrashPath(), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(legacyFatalCrashPath()); !os.IsNotExist(err) {
t.Fatalf("captured legacy fatal dump was not removed: %v", err)
}
report, ok := readPending(t)
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
t.Fatalf("legacy fatal dump was not migrated: report=%+v ok=%v", report, ok)
}
}
func TestAwaitWindowRestoreRequiresNativeConfirmation(t *testing.T) {
ticks := make(chan time.Time)
deadline := make(chan time.Time, 1)
deadline <- time.Now()
if awaitWindowRestoreConfirmation(func() bool { return false }, ticks, deadline) {
t.Fatal("an enqueued show request without native confirmation was treated as restored")
}
}
func TestAwaitWindowRestoreAcceptsConfirmationAfterTick(t *testing.T) {
ticks := make(chan time.Time, 1)
deadline := make(chan time.Time)
var confirmed atomic.Bool
result := make(chan bool, 1)
go func() {
result <- awaitWindowRestoreConfirmation(confirmed.Load, ticks, deadline)
}()
confirmed.Store(true)
ticks <- time.Now()
if !<-result {
t.Fatal("native window confirmation was not accepted")
}
}
func TestSupersededWindowRestoreDoesNotRemoveLatestJournal(t *testing.T) {
path := windowRestoreStatePath()
_ = os.Remove(path)
t.Cleanup(func() { _ = os.Remove(path) })
oldSequence := windowRestoreSequence.Load()
t.Cleanup(func() { windowRestoreSequence.Store(oldSequence) })
latest := windowRestoreState{
SchemaVersion: windowRestoreStateVersion,
PID: os.Getpid(),
AttemptID: 2,
Source: "tray",
StartedAt: "2026-07-24T08:01:00Z",
}
if !writeWindowRestoreState(latest) {
t.Fatal("write latest window restore state")
}
windowRestoreSequence.Store(latest.AttemptID)
NewApp().completeWindowRestoreAttempt(1, windowRestoreState{AttemptID: 1}, true)
got, err := readWindowRestoreState()
if err != nil {
t.Fatalf("latest journal was removed by superseded attempt: %v", err)
}
if got != latest {
t.Fatalf("latest journal changed: got %+v want %+v", got, latest)
}
}
func resetFatalCrashArtifacts(t *testing.T) {
t.Helper()
oldProcessAlive := fatalCrashProcessAlive
fatalCrashProcessAlive = func(int) bool { return false }
removeAllPendingCrashes()
_ = os.Remove(legacyFatalCrashPath())
_ = os.Remove(legacyFatalCrashCoveredPath())
_ = os.RemoveAll(fatalCrashDir())
t.Cleanup(func() {
removeAllPendingCrashes()
_ = os.Remove(legacyFatalCrashPath())
_ = os.Remove(legacyFatalCrashCoveredPath())
_ = os.RemoveAll(fatalCrashDir())
fatalCrashProcessAlive = oldProcessAlive
})
}
func TestWindowRestoreFailureReportSeparatesTimeoutAndSource(t *testing.T) {
report := windowRestoreFailureReport("timeout", "second_instance", "2026-07-24T08:00:00Z")
if report.Label != "windows.window_restore.timeout" || report.TopFrame != "windows.window_restore.second_instance" {
t.Fatalf("report = %+v", report)
}
}
func TestWriteWindowRestoreStateCreatesReadableJournal(t *testing.T) {
path := windowRestoreStatePath()
_ = os.Remove(path)
t.Cleanup(func() { _ = os.Remove(path) })
want := windowRestoreState{
SchemaVersion: windowRestoreStateVersion,
PID: os.Getpid(),
Source: "tray",
StartedAt: "2026-07-24T08:00:00Z",
}
if !writeWindowRestoreState(want) {
t.Fatal("writeWindowRestoreState returned false")
}
got, err := readWindowRestoreState()
if err != nil {
t.Fatalf("readWindowRestoreState: %v", err)
}
if got != want {
t.Fatalf("journal = %+v, want %+v", got, want)
}
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"io"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"time"
"reasonix/internal/config"
)
const (
fatalCrashDirName = "crash-fatal"
fatalCrashLogSuffix = ".log"
fatalCrashCoveredSuffix = ".covered"
legacyFatalCrashFile = "crash-fatal.log"
legacyFatalCrashCoveredFile = "crash-fatal-covered"
)
var fatalCrashProcessAlive = desktopProcessAlive
func fatalCrashDir() string {
return filepath.Join(config.MemoryUserDir(), fatalCrashDirName)
}
func fatalCrashPath() string {
return fatalCrashPathForPID(os.Getpid())
}
func fatalCrashCoveredPath() string {
return fatalCrashCoveredPathForPID(os.Getpid())
}
func fatalCrashPathForPID(pid int) string {
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashLogSuffix)
}
func fatalCrashCoveredPathForPID(pid int) string {
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashCoveredSuffix)
}
func legacyFatalCrashPath() string {
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashFile)
}
func legacyFatalCrashCoveredPath() string {
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashCoveredFile)
}
func markFatalCrashCovered() {
markFatalCrashCoveredForPID(os.Getpid())
}
func markFatalCrashCoveredForPID(pid int) {
path := fatalCrashCoveredPathForPID(pid)
if os.MkdirAll(filepath.Dir(path), 0o700) == nil {
_ = os.WriteFile(path, []byte("structured\n"), 0o600)
}
}
// capturePreviousFatalCrash converts runtime.SetCrashOutput dumps from dead
// processes into the normal scrubbed queue. Per-PID files keep a routine second
// launch from truncating or unlinking the running primary process's dump.
func capturePreviousFatalCrash() {
// Preserve compatibility with the single-file format used by older builds.
// An empty legacy file may still be owned by a running older process, so it
// must be left untouched until it contains a completed crash dump.
captureFatalCrashFile(legacyFatalCrashPath(), legacyFatalCrashCoveredPath(), false)
entries, err := os.ReadDir(fatalCrashDir())
if err != nil {
return
}
for _, entry := range entries {
pid, ok := fatalCrashPID(entry.Name())
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
continue
}
captureFatalCrashFile(
filepath.Join(fatalCrashDir(), entry.Name()),
fatalCrashCoveredPathForPID(pid),
true,
)
}
for _, entry := range entries {
pid, ok := fatalCrashCoveredPID(entry.Name())
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
continue
}
if _, err := os.Stat(fatalCrashPathForPID(pid)); os.IsNotExist(err) {
_ = os.Remove(filepath.Join(fatalCrashDir(), entry.Name()))
}
}
// Best effort: succeeds only when no live/current process artifacts remain.
_ = os.Remove(fatalCrashDir())
}
func fatalCrashPID(name string) (int, bool) {
return fatalCrashPIDWithSuffix(name, fatalCrashLogSuffix)
}
func fatalCrashCoveredPID(name string) (int, bool) {
return fatalCrashPIDWithSuffix(name, fatalCrashCoveredSuffix)
}
func fatalCrashPIDWithSuffix(name, suffix string) (int, bool) {
if !strings.HasSuffix(name, suffix) {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSuffix(name, suffix))
return pid, err == nil && pid > 0
}
func captureFatalCrashFile(path, coveredPath string, removeEmpty bool) {
f, err := os.Open(path)
if err != nil {
return
}
occurredAt := time.Now().UTC()
if info, statErr := f.Stat(); statErr == nil {
occurredAt = info.ModTime().UTC()
}
raw, readErr := io.ReadAll(io.LimitReader(f, maxCrashStackBytes+1))
_ = f.Close()
if readErr != nil || len(strings.TrimSpace(string(raw))) == 0 {
if removeEmpty {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
}
return
}
if _, err := os.Stat(coveredPath); err == nil {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
return
}
stack := sanitizeFatalRuntimeDump(string(raw))
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go.runtime"
report.Label = "go.fatal"
report.ErrorType = "GoRuntimeFatal"
report.ErrorMessage = "Go runtime terminated the desktop process."
report.Stack = stack
report.TopFrame = topFrameFromStack(stack)
report.FingerprintHint = "go.runtime.fatal"
report.OccurredAt = occurredAt.Format(time.RFC3339)
report.Message = sanitizeCrashText("[go.runtime.fatal]\n\n"+stack, maxCrashDetailBytes)
if writePendingReport(report, true) {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
}
}
// sanitizeFatalRuntimeDump removes panic values and preamble text that could
// originate in user-controlled errors, while retaining runtime classification
// and symbolized goroutine stacks for diagnosis.
func sanitizeFatalRuntimeDump(raw string) string {
lines := strings.Split(raw, "\n")
classification := "runtime crash output"
stackStart := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "fatal error:"):
classification = sanitizeCrashText(trimmed, 256)
case strings.HasPrefix(trimmed, "panic:"):
classification = "panic: [redacted panic value]"
}
if strings.HasPrefix(trimmed, "goroutine ") {
stackStart = i
break
}
}
stack := ""
if stackStart >= 0 {
stack = strings.Join(lines[stackStart:], "\n")
}
return sanitizeCrashText(classification+"\n\n"+stack, maxCrashStackBytes)
}
// installFatalCrashOutput asks the Go runtime to mirror unrecovered panics and
// fatal runtime errors to a durable file. The runtime duplicates the descriptor,
// so the file may be closed after SetCrashOutput returns.
func installFatalCrashOutput() {
path := fatalCrashPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return
}
if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil {
_ = f.Close()
_ = os.Remove(path)
return
}
_ = f.Close()
}
+89 -21
View File
@@ -6,6 +6,11 @@ import (
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"reasonix/internal/config"
)
@@ -16,12 +21,25 @@ import (
// otherwise never surface a single report. The resend is gated on the same
// desktop.telemetry opt-out as the launch ping.
const pendingCrashFile = "crash-pending.json"
const (
pendingCrashFile = "crash-pending.json" // legacy single-report path
pendingCrashQueueDir = "crash-pending"
maxPendingCrashes = 10
)
var (
pendingCrashMu sync.Mutex
pendingCrashSequence atomic.Uint64
)
func pendingCrashPath() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
}
func pendingCrashDir() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
}
// recoverToPending records a panicking goroutine to the pending-crash file and
// re-raises, so the process still crashes exactly as before — the stack is now
// shipped next launch instead of lost.
@@ -36,44 +54,84 @@ func (a *App) recoverToPending(site string) {
func writePendingCrash(site string, r any, stack []byte) {
stackText := string(stack)
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s: %v\n\n%s", site, r, stackText), maxCrashDetailBytes)
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go"
report.Label = sanitizeCrashField(site, 64)
report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
report.ErrorMessage = sanitizeCrashText(fmt.Sprint(r), maxCrashFieldBytes)
report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
_ = writePendingReport(report, true)
if writePendingReport(report, true) {
markFatalCrashCovered()
}
}
func writePendingReport(report crashReport, overwrite bool) bool {
_ = overwrite // retained for source compatibility; the queue never overwrites.
body, err := json.Marshal(report)
if err != nil {
return false
}
path := pendingCrashPath()
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
dir := pendingCrashDir()
if os.MkdirAll(dir, 0o700) != nil {
return false
}
if overwrite {
return os.WriteFile(path, body, 0o644) == nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
pendingCrashMu.Lock()
defer pendingCrashMu.Unlock()
name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
strconv.Itoa(os.Getpid()) + "-" +
strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
path := filepath.Join(dir, name)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false
}
defer f.Close()
n, err := f.Write(body)
if err != nil || n != len(body) {
closeErr := f.Close()
if err != nil || closeErr != nil || n != len(body) {
_ = os.Remove(path)
return false
}
paths := pendingCrashQueuePaths()
for len(paths) > maxPendingCrashes {
_ = os.Remove(paths[0])
paths = paths[1:]
}
return true
}
func pendingCrashQueuePaths() []string {
entries, err := os.ReadDir(pendingCrashDir())
if err != nil {
return nil
}
paths := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
}
}
sort.Strings(paths)
return paths
}
func pendingCrashPaths() []string {
paths := make([]string, 0, maxPendingCrashes+1)
if _, err := os.Stat(pendingCrashPath()); err == nil {
paths = append(paths, pendingCrashPath())
}
return append(paths, pendingCrashQueuePaths()...)
}
func removeAllPendingCrashes() {
_ = os.Remove(pendingCrashPath())
_ = os.RemoveAll(pendingCrashDir())
_ = os.Remove(fatalCrashCoveredPath())
}
func (a *App) goSafe(site string, fn func()) {
go func() {
defer a.recoverToPending(site)
@@ -94,26 +152,36 @@ func (a *App) flushPendingCrash() {
if config.SafeModeRequested() {
return
}
path := pendingCrashPath()
body, err := readFileUTF8(path)
if err != nil {
paths := pendingCrashPaths()
if len(paths) == 0 {
return
}
cfg, err := config.Load()
if err != nil || !cfg.DesktopTelemetry() {
_ = os.Remove(path)
if err != nil {
return
}
var r crashReport
if json.Unmarshal(body, &r) != nil {
_ = os.Remove(path)
if !cfg.DesktopTelemetry() {
removeAllPendingCrashes()
return
}
c, err := httpClient()
if err != nil {
return
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) == nil {
for _, path := range paths {
body, readErr := readFileUTF8(path)
if readErr != nil {
continue
}
var r crashReport
if json.Unmarshal(body, &r) != nil {
_ = os.Remove(path)
continue
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
break
}
_ = os.Remove(path)
}
_ = os.Remove(pendingCrashDir())
}
+28 -20
View File
@@ -13,7 +13,11 @@ import (
func readPending(t *testing.T) (crashReport, bool) {
t.Helper()
body, err := os.ReadFile(pendingCrashPath())
paths := pendingCrashPaths()
if len(paths) == 0 {
return crashReport{}, false
}
body, err := os.ReadFile(paths[0])
if err != nil {
return crashReport{}, false
}
@@ -25,7 +29,7 @@ func readPending(t *testing.T) (crashReport, bool) {
}
func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
t.Cleanup(removeAllPendingCrashes)
func() {
defer func() {
@@ -45,7 +49,7 @@ func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
if r.Kind != "crash" {
t.Errorf("kind = %q, want crash", r.Kind)
}
if !strings.Contains(r.Message, "[go panic] unit:") {
if !strings.Contains(r.Message, "[go panic] unit") {
t.Errorf("message missing site prefix: %q", r.Message)
}
if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
@@ -57,7 +61,7 @@ func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
}
func TestWritePendingCrashCaps(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
t.Cleanup(removeAllPendingCrashes)
writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
r, ok := readPending(t)
if !ok {
@@ -68,8 +72,8 @@ func TestWritePendingCrashCaps(t *testing.T) {
}
}
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
func TestWritePendingReportQueuesWithoutOverwritingExistingCrash(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
@@ -80,8 +84,8 @@ func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if writePendingReport(hang, false) {
t.Fatal("writePendingReport overwrite=false should not replace existing report")
if !writePendingReport(hang, false) {
t.Fatal("writePendingReport should enqueue the second report")
}
after, ok := readPending(t)
if !ok {
@@ -90,10 +94,13 @@ func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
if got := len(pendingCrashPaths()); got != 2 {
t.Fatalf("pending reports = %d, want 2", got)
}
}
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
func TestWritePendingReportQueueIsBoundedUnderConcurrentWriters(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
@@ -120,27 +127,28 @@ func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
close(start)
done.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
if got := successes.Load(); got != writers {
t.Fatalf("successful queued writers = %d, want %d", got, writers)
}
if _, ok := readPending(t); !ok {
t.Fatal("expected one pending report")
if got := len(pendingCrashPaths()); got != maxPendingCrashes {
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
t.Cleanup(removeAllPendingCrashes)
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
longHex := "0123456789abcdef0123456789abcdef"
writePendingCrash("unit", "boom api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
privatePanicValue := "private user prompt contents"
writePendingCrash("unit", privatePanicValue+" api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
for _, leaked := range []string{apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
for _, leaked := range []string{privatePanicValue, apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
if strings.Contains(freeText, leaked) {
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
}
@@ -151,7 +159,7 @@ func TestFlushPendingCrashSendsAndClears(t *testing.T) {
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
os.Remove(pendingCrashPath())
removeAllPendingCrashes()
})
version = "v9.9.9"
@@ -178,7 +186,7 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
oldVersion := version
t.Cleanup(func() {
version = oldVersion
os.Remove(pendingCrashPath())
removeAllPendingCrashes()
})
version = "dev"
@@ -195,7 +203,7 @@ func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
oldVersion := version
t.Cleanup(func() {
version = oldVersion
os.Remove(pendingCrashPath())
removeAllPendingCrashes()
})
version = "v9.9.9"
+26 -10
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
@@ -239,6 +238,13 @@ func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
a.clearDeferredRebuild(tabID)
return
}
a.mu.RLock()
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if path != "" && a.attachExistingSessionRuntime(tab, path, a.ctx) {
a.clearDeferredRebuild(tabID)
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
@@ -289,6 +295,7 @@ func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
tab.buildCancel = cancel
tab.Ready = false
clearTabStartupError(tab)
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
@@ -366,16 +373,25 @@ func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
if path == "" {
return true // nothing to probe; let the rebuild decide
}
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
key := sessionRuntimeKey(path)
a.mu.RLock()
rt := a.runtimeBySessionKey[key]
ownedByTab := rt != nil && rt.Owner == tab
ownedByOther := rt != nil && rt.Owner != nil && rt.Owner != tab && a.runtimeOwnerLiveLocked(rt)
a.mu.RUnlock()
if ownedByOther {
return false
}
if ownedByTab && tab.sessionLeaseRuntimeKey() == key {
return true
}
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
var leaseErr *agent.SessionLeaseError
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
// This process (usually this very tab) holds the probed path;
// the blocking lease is some other path. Attempt the rebuild
// and let its own lease checks decide.
return true
}
if sameCurrentProcessLease(err) {
// The registry ruled out a live sibling owner above, so this is an
// orphaned current-process lease. Let the rebuild helper reclaim it
// under runtimeRebuildMu instead of looping against our own marker.
return true
}
return !errors.Is(err, agent.ErrSessionLeaseHeld)
}
+9 -3
View File
@@ -5,21 +5,27 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build",
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
"check:bundle": "node scripts/check-bundle-budget.mjs",
"preview": "vite preview",
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/workspace-layout.test.ts",
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citationLine truncated
"test:remote": "tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && tsx src/__tests__/provider-trust-dialog.test.tsx && tsx src/__tests__/workbench-target.test.ts",
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote"
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote && pnpm test:performance"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"@tanstack/react-virtual": "^3.14.3",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"gsap": "^3.15.0",
"highlight.js": "^11.10.0",
"katex": "^0.17.0",
+28 -12
View File
@@ -17,6 +17,12 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.14.3
version: 3.14.3([email protected]([email protected]))([email protected])
'@xterm/addon-fit':
specifier: 0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: 6.0.0
version: 6.0.0
gsap:
specifier: ^3.15.0
version: 3.15.0
@@ -646,6 +652,12 @@ packages:
babel-plugin-react-compiler:
optional: true
'@xterm/[email protected]':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/[email protected]':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
[email protected]:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
@@ -894,8 +906,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
[email protected]1:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
[email protected]2:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
[email protected]:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
@@ -1287,8 +1299,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]2:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
[email protected]6:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1320,8 +1332,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
[email protected].15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
[email protected].23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
@@ -2066,6 +2078,10 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.0.16(@types/[email protected])([email protected])([email protected])([email protected])
'@xterm/[email protected]': {}
'@xterm/[email protected]': {}
[email protected]: {}
[email protected]: {}
@@ -2324,7 +2340,7 @@ snapshots:
dependencies:
dequal: 2.0.3
[email protected]1:
[email protected]2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2781,7 +2797,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.21
dompurify: 3.4.11
dompurify: 3.4.12
es-toolkit: 1.49.0
katex: 0.16.47
khroma: 2.1.0
@@ -2994,7 +3010,7 @@ snapshots:
[email protected]: {}
[email protected]2: {}
[email protected]6: {}
[email protected]: {}
@@ -3029,9 +3045,9 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
[email protected].15:
[email protected].23:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -3308,7 +3324,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.23
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -0,0 +1,57 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { basename, resolve } from "node:path";
import { gzipSync } from "node:zlib";
const distDir = resolve("dist");
const indexPath = resolve(distDir, "index.html");
const html = readFileSync(indexPath, "utf8");
function gzipBytes(path) {
return gzipSync(readFileSync(path), { level: 9 }).byteLength;
}
function initialAssetPaths(extension) {
const pattern = extension === ".js"
? /<(?:script|link)\b[^>]+(?:src|href)=["']([^"']+\.js)["'][^>]*>/g
: /<link\b[^>]+href=["']([^"']+\.css)["'][^>]*>/g;
return [...new Set([...html.matchAll(pattern)].map((match) => resolve(distDir, match[1])))];
}
function formatKiB(bytes) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
function assertBudget(label, actual, budget) {
if (actual > budget) {
throw new Error(`${label} is ${formatKiB(actual)}; budget is ${formatKiB(budget)}`);
}
process.stdout.write(` PASS ${label}: ${formatKiB(actual)} / ${formatKiB(budget)}\n`);
}
const initialJS = initialAssetPaths(".js");
const initialCSS = initialAssetPaths(".css");
if (!initialJS.length) throw new Error("no initial JavaScript assets found in dist/index.html");
if (!initialCSS.length) throw new Error("no initial CSS assets found in dist/index.html");
const initialJSGzip = initialJS.reduce((total, path) => total + gzipBytes(path), 0);
const initialCSSGzip = initialCSS.reduce((total, path) => total + gzipBytes(path), 0);
const largestInitialJS = Math.max(...initialJS.map(gzipBytes));
const largestInitialJSRaw = Math.max(...initialJS.map((path) => statSync(path).size));
const localeChunks = readdirSync(resolve(distDir, "assets"))
.filter((name) => /^(?:zh|zh-TW)-.+\.js$/.test(name))
.map((name) => resolve(distDir, "assets", name));
console.log("\nbundle budgets");
assertBudget("initial JavaScript gzip", initialJSGzip, 430 * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 295 * 1024);
assertBudget("initial CSS gzip", initialCSSGzip, 112 * 1024);
if (localeChunks.length !== 2) {
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
}
for (const path of localeChunks) {
assertBudget(`${basename(path)} gzip`, gzipBytes(path), 52 * 1024);
}
const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0);
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_250 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024);
@@ -18,6 +18,8 @@ const transpiled = ts.transpileModule(source, {
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const {
dismissedTodoKeyForScope,
resolveTodoPanelTodos,
sameTodoList,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldOpenTodoPanelByDefault,
@@ -36,6 +38,27 @@ const activeTodos = [
{ content: "Ship the fix", status: "pending" },
];
assert.deepEqual(
resolveTodoPanelTodos([], activeTodos),
[],
"an authoritative empty canonical list clears the transcript fallback",
);
assert.deepEqual(
resolveTodoPanelTodos(undefined, activeTodos),
activeTodos,
"an unavailable canonical list falls back to the transcript snapshot",
);
assert.equal(
sameTodoList(activeTodos, activeTodos.map((todo) => ({ ...todo }))),
true,
"equivalent canonical lists do not churn meta state",
);
assert.equal(
sameTodoList(activeTodos, [{ ...activeTodos[0], status: "completed" }, activeTodos[1]]),
false,
"canonical status changes invalidate meta state",
);
assert.equal(
shouldShowTodoPanel("todo-final", null, completedTodos),
true,
+366 -84
View File
@@ -33,10 +33,13 @@ import {
Cpu,
Palette,
X,
TerminalSquare,
} from "lucide-react";
import { useToast } from "./lib/toast";
import { useGoalActionHandler } from "./lib/goalAction";
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
import { asArray } from "./lib/array";
import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT, tabMetaFallbackDelay } from "./lib/tabMetaRefresh";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT, type Translator } from "./lib/i18n";
import { localizedNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered, onWorkbenchTarget, openExternal } from "./lib/bridge";
@@ -77,9 +80,13 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { ExternalOpener } from "./components/ExternalOpener";
import { startTerminalEventBridge } from "./lib/terminalEvents";
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
import { useTerminalStore } from "./store/terminal";
import { parseTodos } from "./lib/tools";
import {
dismissedTodoKeyForScope,
resolveTodoPanelTodos,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldShowTodoPanel,
@@ -106,6 +113,7 @@ import {
} from "./lib/types";
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
import { workspaceTreeVisitId } from "./lib/workspaceTreeMemory";
import {
composerProfileFromMeta,
composerProfileFromTab,
@@ -187,6 +195,11 @@ import { useGlobalShortcut } from "./lib/keyboardShortcuts";
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
import { continueDelivery } from "./lib/deliveryContinue";
import {
activateGoalAndSubmitOnTab,
workbenchTargetToken,
type WorkbenchTargetToken,
} from "./lib/goalSubmit";
import logoWordmark from "./assets/logo-wordmark.svg";
function noticePreviewMockEnabled(): boolean {
@@ -262,6 +275,7 @@ function NoticePreviewPanel() {
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
const CHAT_MIN_WIDTH = 400;
const CHAT_COMFORT_MIN_WIDTH = 560;
@@ -406,12 +420,13 @@ type SidebarImConnection = {
allowlistUsers: string[];
allowlistMatched: boolean;
};
type DesktopNavigationInput =
type DesktopNavigationIntent =
| { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string }
| { kind: "blank"; scope: string; workspaceRoot: string }
| { kind: "delivery-worktree"; workspaceRoot: string }
| { kind: "sidebar-im"; connection: SidebarImConnection }
| { kind: "resume-session"; session: SessionMeta };
type DesktopNavigationInput = DesktopNavigationIntent & { navigationIntentSeq: number };
type PendingDesktopNavigationRequest = PendingNavigationRequest<DesktopNavigationInput>;
type SidebarImTopicSource = {
platform: SidebarImPlatform;
@@ -1032,6 +1047,7 @@ function TextSizeHotkeys() {
export default function App() {
const {
state,
liveStore,
activeTabId,
sendToTab,
recoverDeliveryToTab,
@@ -1044,13 +1060,12 @@ export default function App() {
answerQuestion,
setControllerMode,
setCollaborationMode: setControllerCollaborationMode,
setCollaborationModeForTab: setControllerCollaborationModeForTab,
setToolApprovalMode: setControllerToolApprovalMode,
setToolApprovalModeForTab: setControllerToolApprovalModeForTab,
setGoal: setControllerGoal,
setComposerProfileForTab: setControllerComposerProfileForTab,
setGoalForTab: setControllerGoalForTab,
resumeGoalForTab: resumeControllerGoalForTab,
clearGoal: clearControllerGoal,
clearGoalForTab: clearControllerGoalForTab,
clearSession,
listSessions,
listTrashedSessions,
@@ -1078,6 +1093,7 @@ export default function App() {
openTopicSession,
activateTopic,
noteNavigationIntent,
isNavigationIntentCurrent,
syncActiveTab,
ensureBlankTab,
ensureBlankSurface,
@@ -1116,6 +1132,7 @@ export default function App() {
const remoteStatuses = useRemoteStore((s) => s.statuses);
const [workbenchTarget, setWorkbenchTarget] = useState<WorkbenchActiveTarget>({ kind: "local" });
const { showToast } = useToast();
const { runGoalAction, handleGoalActionError } = useGoalActionHandler();
const setRemoteHosts = useRemoteStore((s) => s.setHosts);
const hydrateRemoteStatuses = useRemoteStore((s) => s.hydrateStatuses);
const requestRemoteExplorer = useRemoteStore((s) => s.openExplorer);
@@ -1186,6 +1203,7 @@ export default function App() {
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
// workspace changes, git history, and session metadata after AI tool writes.
useEffect(() => {
startTerminalEventBridge();
const unsub = onEvent((e) => {
if (e.kind === "turn_done") {
setDockRefreshKey((v) => v + 1);
@@ -1477,7 +1495,18 @@ export default function App() {
const footerHeightRef = useRef(0);
const footerRef = useRef<HTMLElement>(null);
const activeTabIdRef = useRef(activeTabId);
const commitThenSendRef = useRef<(tabId: string, displayText: string, submitText?: string, structured?: StructuredInvocationSubmit) => Promise<void>>(async () => {});
const commitThenSendRef = useRef<(
tabId: string,
displayText: string,
submitText?: string,
structured?: StructuredInvocationSubmit,
initialGoal?: {
goal: string;
target: WorkbenchTargetToken;
collaborationMode: CollaborationMode;
toolApprovalMode: ToolApprovalMode;
},
) => Promise<void>>(async () => {});
const handleInvocationMetadataChange = useCallback((metadata: InvocationMetadataMap) => {
const sourceTabId = activeTabIdRef.current;
if (!sourceTabId) return;
@@ -1517,8 +1546,14 @@ export default function App() {
const storedWorkspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth;
const workspacePanelRenderWidth = liveWorkspacePanelRenderWidth ?? storedWorkspacePanelRenderWidth;
// The terminal is a bottom drawer, so it must remain available even when a
// narrow window cannot satisfy the right-side workspace panel's width.
const workspacePanelRenderable =
workspacePanelOpen && (workspacePanelMaximized || workspacePanelRenderWidth >= rightDockMinRenderWidth);
workspacePanelOpen && (
workspacePanelMaximized ||
rightDockMode === "terminal" ||
workspacePanelRenderWidth >= rightDockMinRenderWidth
);
const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized;
const resolveLiveWorkspacePanelRenderWidth = useCallback(
(preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
@@ -1546,6 +1581,9 @@ export default function App() {
? planRevisionInsertRequest.request
: null;
const composerInsertRequest = activeTabId ? composerInsertRequestsByTab[activeTabId] ?? null : null;
const handleRevisionActiveChange = useCallback((active: boolean) => {
setWorkspaceInsertTarget(active ? "planRevision" : "composer");
}, []);
const selectedTextRequest = activeTabId ? selectedTextRequestsByTab[activeTabId] ?? null : null;
const prefillSubagentCommand = useCallback((command: string) => {
if (!activeTabId) return;
@@ -1565,6 +1603,14 @@ export default function App() {
state.sessionGen,
workspaceControllerEpoch,
].join("\u0000");
// A topic may contain multiple saved sessions; the concrete session path is
// the runtime conversation identity, with topic/tab ids only as fallbacks.
const workspaceTreeMemoryKey = [
activeTab?.scope ?? "",
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
activeTab?.sessionPath || state.meta?.sessionPath || activeTab?.topicId || activeTabId || "",
].join("\u0000");
const workspaceTreeMemoryVisitId = workspaceTreeVisitId(workspaceTreeMemoryKey);
const sidebarImDetailConnection = useMemo(
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
[sidebarImConnections, sidebarImDetailConnectionId],
@@ -1613,7 +1659,12 @@ export default function App() {
const toolApprovalMode = composerProfile.toolApprovalMode;
const tokenMode: TokenMode = composerProfile.tokenMode;
const runtimeTransitioning = Boolean(activeTabId && runtimeTransitionsByTab[activeTabId]) || workbenchTargetTransitioning(workbenchTarget);
const controllerReady = state.meta?.ready === true && !state.backendActivationPending && !runtimeTransitioning;
const controllerReady =
state.meta?.ready === true &&
(!state.meta.runtime || state.meta.runtime.phase === "ready") &&
!state.meta.startupErr &&
!state.backendActivationPending &&
!runtimeTransitioning;
// Single footer decision surface. Composer stays mounted underneath and is
// only visually/a11y-hidden so per-session draft caches survive.
const decisionSurface = useMemo((): DecisionSurfaceKind | null => {
@@ -1653,6 +1704,16 @@ export default function App() {
},
[activeTabId, composerProfile],
);
const patchComposerProfileForTab = useCallback(
(tabId: string, patch: Partial<Omit<ComposerProfile, "pending">>, pendingFields: ComposerProfileField[]) => {
if (!tabId) return;
setComposerProfilesByTab((current) => {
const base = current[tabId] ?? composerProfileFromTab(tabMetas.find((tab) => tab.id === tabId));
return patchComposerProfile(current, tabId, base, patch, pendingFields);
});
},
[tabMetas],
);
const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId);
const visibleTabId = activeTabId;
const visibleTabs = useMemo(() => {
@@ -1727,14 +1788,15 @@ export default function App() {
);
const applyCollaborationMode = useCallback(
async (m: CollaborationMode): Promise<void> => {
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, m === "plan");
if (m === "goal") {
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false);
patchActiveComposerProfile({ collaborationMode: "normal", goalDraftMode: true, goal: "" }, ["collaborationMode", "goal"]);
return setControllerCollaborationMode("normal");
}
patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]);
if (goal.trim()) await clearControllerGoal();
await setControllerCollaborationMode(m);
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, m === "plan");
patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]);
},
[activeTabId, clearControllerGoal, goal, patchActiveComposerProfile, setControllerCollaborationMode],
);
@@ -1764,18 +1826,35 @@ export default function App() {
}
applyToolApprovalMode(next.mode);
}, [activeTabId, applyToolApprovalMode, toolApprovalMode]);
const applyGoal = useCallback(
async (nextGoal: string): Promise<void> => {
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false);
const patchActivatedGoalForTab = useCallback(
(tabId: string, nextGoal: string): void => {
const trimmed = nextGoal.trim();
patchActiveComposerProfile({
patchComposerProfileForTab(tabId, {
collaborationMode: trimmed ? "goal" : "normal",
goalDraftMode: false,
goal: trimmed,
}, ["collaborationMode", "goal"]);
await (trimmed ? setControllerGoal(trimmed) : clearControllerGoal());
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, tabId, false);
},
[activeTabId, clearControllerGoal, patchActiveComposerProfile, setControllerGoal],
[patchComposerProfileForTab],
);
const applyGoalForTab = useCallback(
async (tabId: string, nextGoal: string): Promise<void> => {
if (!tabId) return;
const trimmed = nextGoal.trim();
// Activate the backend Goal first. Only then patch the local profile so a
// failed SetGoalForTab cannot leave the Composer thinking a Goal is active.
await (trimmed ? setControllerGoalForTab(tabId, trimmed) : clearControllerGoalForTab(tabId));
patchActivatedGoalForTab(tabId, trimmed);
},
[clearControllerGoalForTab, patchActivatedGoalForTab, setControllerGoalForTab],
);
const applyGoal = useCallback(
async (nextGoal: string): Promise<void> => {
if (!activeTabId) return;
await applyGoalForTab(activeTabId, nextGoal);
},
[activeTabId, applyGoalForTab],
);
const applyTokenMode = useCallback(
async (m: TokenMode): Promise<void> => {
@@ -1807,20 +1886,27 @@ export default function App() {
// Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the
// tool-permission axis while preserving the Ask/Auto base mode.
const cycleMode = useCallback(() => {
applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan");
}, [applyCollaborationMode, collaborationMode]);
runGoalAction(() => applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan"));
}, [applyCollaborationMode, collaborationMode, runGoalAction]);
// Switching models rebuilds the controller, which starts in normal mode — so
// re-apply the current mode, or the pill would say plan/YOLO while the fresh
// controller silently uses normal gating.
const switchModel = useCallback(
async (name: string) => {
await setModel(name);
await setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile));
await setControllerToolApprovalMode(toolApprovalMode);
if (goal.trim()) await setControllerGoal(goal);
const switched = await setModel(name);
if (!switched) return false;
if (!activeTabId) return false;
const profileApplied = await setControllerComposerProfileForTab(
activeTabId,
controllerComposerProfileCollaborationMode(composerProfile),
toolApprovalMode,
goal,
{ propagateError: true },
);
return profileApplied;
},
[composerProfile, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, setModel, toolApprovalMode],
[activeTabId, composerProfile, goal, setControllerComposerProfileForTab, setModel, toolApprovalMode],
);
// Startup and workspace/model rebuilds create a fresh controller in normal
@@ -1828,11 +1914,17 @@ export default function App() {
// where the user picked YOLO while boot was still loading and the legacy
// SetBypass binding was a harmless no-op.
useEffect(() => {
if (!controllerReady) return;
void setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile));
void setControllerToolApprovalMode(toolApprovalMode);
if (goal.trim()) void setControllerGoal(goal);
}, [composerProfile, controllerReady, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, toolApprovalMode]);
if (!controllerReady || !activeTabId) return;
runGoalAction(async () => {
await setControllerComposerProfileForTab(
activeTabId,
controllerComposerProfileCollaborationMode(composerProfile),
toolApprovalMode,
goal,
{ propagateError: true },
);
});
}, [activeTabId, composerProfile, controllerReady, goal, runGoalAction, setControllerComposerProfileForTab, toolApprovalMode]);
// The live task list pinned above the composer comes from the most recent
// successful top-level todo_write result; failed or still-running attempts do
@@ -1856,7 +1948,11 @@ export default function App() {
return null;
}, [state.items]);
const todoItem = todoEntry?.item ?? null;
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
const metaTodos = state.meta?.canonicalTodos;
const todos = useMemo(
() => resolveTodoPanelTodos(metaTodos, todoItem ? parseTodos(todoItem.args) : []),
[metaTodos, todoItem],
);
const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
@@ -1890,12 +1986,12 @@ export default function App() {
applyThemeScene(sessionHasContent ? "task" : "home");
}, [sessionHasContent]);
const getSessionMarkdown = useCallback(
() => sessionItemsToMarkdown(sessionTitle, state.items, state.live),
[sessionTitle, state.items, state.live],
() => sessionItemsToMarkdown(sessionTitle, state.items, liveStore.getSnapshot(activeTabId) ?? state.live),
[activeTabId, liveStore, sessionTitle, state.items, state.live],
);
const getSessionJson = useCallback(
() => sessionItemsToJson(sessionTitle, state.items, state.live),
[sessionTitle, state.items, state.live],
() => sessionItemsToJson(sessionTitle, state.items, liveStore.getSnapshot(activeTabId) ?? state.live),
[activeTabId, liveStore, sessionTitle, state.items, state.live],
);
useEffect(() => {
@@ -2027,7 +2123,7 @@ export default function App() {
}
const model = /^\/model\s+(\S+)$/.exec(trimmed);
if (model) {
void switchModel(model[1]);
await switchModel(model[1]);
return;
}
if (trimmed === "/memory") {
@@ -2065,8 +2161,29 @@ export default function App() {
}
if (collaborationMode === "goal" && !goal.trim()) {
if (!controllerReady) return;
await applyGoal(trimmed);
await commitThenSendRef.current(sourceTabId, trimmed, `/goal ${submitText.trim()}`);
const sourceTarget = workbenchTargetToken(workbenchTarget);
if (!sourceTarget) throw new Error(t("composer.workspaceStarting"));
await activateGoalAndSubmitOnTab({
tabId: sourceTabId,
target: sourceTarget,
displayText: trimmed,
submitText,
structured,
sendToTab: (tabId, nextGoal, display, routedSubmit, routedStructured, target) =>
commitThenSendRef.current(
tabId,
display,
routedSubmit,
routedStructured,
target ? {
goal: nextGoal,
target,
collaborationMode: controllerComposerProfileCollaborationMode(composerProfile),
toolApprovalMode,
} : undefined,
),
});
patchActivatedGoalForTab(sourceTabId, trimmed);
return;
}
const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed);
@@ -2114,13 +2231,17 @@ export default function App() {
return;
}
if (!controllerReady) return;
await setControllerCollaborationModeForTab(sourceTabId, controllerComposerProfileCollaborationMode(composerProfile));
await setControllerToolApprovalModeForTab(sourceTabId, toolApprovalMode);
if (goal.trim()) await setControllerGoalForTab(sourceTabId, goal);
const profileApplied = await setControllerComposerProfileForTab(
sourceTabId,
controllerComposerProfileCollaborationMode(composerProfile),
toolApprovalMode,
goal,
);
if (!profileApplied) return;
await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured);
},
[activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab,
setControllerCollaborationModeForTab, setControllerGoalForTab, setControllerToolApprovalModeForTab, switchModel, t, toolApprovalMode, showToast],
patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, toolApprovalMode, showToast, workbenchTarget],
);
const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => {
@@ -2129,9 +2250,37 @@ export default function App() {
await steerForTab(sourceTabId, text.trim());
}, [activeTabId, steerForTab, t]);
const refreshTabMetas = useCallback(async (): Promise<TabMeta[]> => {
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
setTabMetas(tabs);
const setCollaborationModeFromUi = useCallback((mode: CollaborationMode) => {
runGoalAction(() => applyCollaborationMode(mode));
}, [applyCollaborationMode, runGoalAction]);
const clearGoalFromUi = useCallback(() => {
runGoalAction(() => applyGoal(""));
}, [applyGoal, runGoalAction]);
const switchModelFromUi = useCallback(async (name: string): Promise<boolean> => {
try {
return await switchModel(name);
} catch (error) {
handleGoalActionError(error);
return false;
}
}, [handleGoalActionError, switchModel]);
const tabMetaRefreshCoordinatorRef = useRef<ReturnType<typeof createBoundedRefreshCoordinator<TabMeta[]>> | null>(null);
if (!tabMetaRefreshCoordinatorRef.current) {
tabMetaRefreshCoordinatorRef.current = createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT);
}
const refreshTabMetas = useCallback(async (
apply?: () => boolean,
options?: { afterMutation?: boolean },
): Promise<TabMeta[]> => {
const result = await tabMetaRefreshCoordinatorRef.current!.run(
async () => asArray(await app.ListTabs().catch(() => [] as TabMeta[])),
options?.afterMutation ? { invalidate: true } : undefined,
);
const tabs = result.value;
if (result.latest && (!apply || apply())) {
setTabMetas((current) => sameTabMetaLists(current, tabs) ? current : tabs);
}
return tabs;
}, []);
const seedActiveTabMeta = useCallback((tab: TabMeta): void => {
@@ -2152,11 +2301,14 @@ export default function App() {
useEffect(() => {
const unsub = onEvent((e) => {
if (shouldRefreshTabMetaForEvent(e.kind)) {
void refreshTabMetas(undefined, { afterMutation: true });
}
if (e.kind !== "turn_done") return;
const turnTabId = resolvePlanRestoreTabId(e.tabId, activeTabIdRef.current);
window.setTimeout(() => {
setProjectRevision((value) => value + 1);
refreshTabMetas().then((tabs) => {
refreshTabMetas(undefined, { afterMutation: true }).then((tabs) => {
if (!turnTabId) return;
const tab = tabs.find((item) => item.id === turnTabId);
const baseProfile = tab ? composerProfileFromTab(tab) : defaultComposerProfile;
@@ -2189,15 +2341,41 @@ export default function App() {
}, [activeTab?.scope, activeTab?.workspaceRoot]);
useEffect(() => {
void refreshTabMetas();
const id = window.setInterval(() => void refreshTabMetas(), 2000);
return () => window.clearInterval(id);
let cancelled = false;
let timer: number | undefined;
const schedule = () => {
if (cancelled) return;
timer = window.setTimeout(() => {
void refreshTabMetas();
schedule();
}, tabMetaFallbackDelay(document.visibilityState));
};
const refreshAndSchedule = () => {
if (timer !== undefined) window.clearTimeout(timer);
timer = undefined;
void refreshTabMetas();
schedule();
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refreshAndSchedule();
else {
if (timer !== undefined) window.clearTimeout(timer);
schedule();
}
};
refreshAndSchedule();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [refreshTabMetas]);
useEffect(() => {
return onProjectTreeChanged(() => {
setProjectRevision((value) => value + 1);
void refreshTabMetas();
void refreshTabMetas(undefined, { afterMutation: true });
});
}, [refreshTabMetas]);
@@ -2564,6 +2742,25 @@ export default function App() {
[openWorkspacePanel],
);
const openTerminalForPath = useCallback(
(path = ".") => {
openRightDockMode("terminal");
if (!activeTabId) return;
void useTerminalStore.getState().createSession(activeTabId, path || ".", "default").catch(() => {});
},
[activeTabId, openRightDockMode],
);
useGlobalShortcut("terminal.toggle", () => {
if (workspacePanelRenderable && rightDockMode === "terminal") closeWorkspacePanel();
else openRightDockMode("terminal");
}, [closeWorkspacePanel, openRightDockMode, rightDockMode, workspacePanelRenderable]);
useGlobalShortcut("terminal.newSession", () => {
if (!activeTabId) return;
openRightDockMode("terminal");
void useTerminalStore.getState().createSession(activeTabId, ".", "default").catch(() => {});
}, [activeTabId, openRightDockMode]);
useEffect(() => {
if (!remoteExplorerOpen) return;
openRightDockMode("remote");
@@ -2693,6 +2890,21 @@ export default function App() {
}
}, [activeTabId, state.approval, workspaceInsertTarget]);
const addTerminalOutputToComposer = useCallback(async (sessionId: string) => {
if (!activeTabId) return;
try {
const output = await app.TerminalOutputForTab(activeTabId, sessionId);
const formatted = formatTerminalOutputForComposer(output);
if (!formatted) {
showToast(t("terminal.noOutput"), "info");
return;
}
addWorkspaceTextToComposer(formatted);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}, [activeTabId, addWorkspaceTextToComposer, showToast, t]);
const addSelectedTextToComposer = useCallback((text: string) => {
const selected = text.trim();
if (!activeTabId || !selected) return;
@@ -2731,18 +2943,28 @@ export default function App() {
// activation around it.
const tabSwitchSeqRef = useRef(0);
const tabSwitchRunningRef = useRef(false);
const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta }> | null>(null);
const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta; navigationIntentSeq: number }> | null>(null);
const enqueueTabSwitch = useCallback(
(tabId: string, optimisticTab?: TabMeta): Promise<void> =>
enqueueNavigationRequest(
(tabId: string, optimisticTab?: TabMeta): Promise<void> => {
// Claim the shared navigation epoch at click time, before this request
// can wait behind an older tab switch. That immediately invalidates any
// in-flight blank/topic completion from a previous user intent.
const navigationIntentSeq = noteNavigationIntent();
return enqueueNavigationRequest(
{ seqRef: tabSwitchSeqRef, runningRef: tabSwitchRunningRef, pendingRef: tabSwitchPendingRef },
{ tabId, optimisticTab },
{ tabId, optimisticTab, navigationIntentSeq },
async (request) => {
await switchTab(request.tabId, request.optimisticTab);
await refreshTabMetas();
if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return;
await switchTab(request.tabId, request.optimisticTab, request.navigationIntentSeq);
if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return;
await refreshTabMetas(
() => isNavigationIntentCurrent(request.navigationIntentSeq),
{ afterMutation: true },
);
},
),
[switchTab, refreshTabMetas],
);
},
[isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, switchTab],
);
const handleTabChange = useCallback((id: string) => {
@@ -2773,7 +2995,7 @@ export default function App() {
return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId }));
});
await closeTab(id);
await refreshTabMetas();
await refreshTabMetas(undefined, { afterMutation: true });
setTabRevealSignal((signal) => signal + 1);
}, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]);
@@ -2790,7 +3012,7 @@ export default function App() {
setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === nextActiveTabId })));
void enqueueTabSwitch(nextActiveTabId, selected);
}
await refreshTabMetas();
await refreshTabMetas(undefined, { afterMutation: true });
setTabRevealSignal((signal) => signal + 1);
}, [closeTab, closeTransientOverlays, enqueueTabSwitch, refreshTabMetas, tabMetas]);
@@ -2802,7 +3024,7 @@ export default function App() {
return ordered.length === current.length ? ordered : current;
});
await reorderTabs(ids);
await refreshTabMetas();
await refreshTabMetas(undefined, { afterMutation: true });
setTabRevealSignal((signal) => signal + 1);
}, [refreshTabMetas, reorderTabs]);
@@ -2870,11 +3092,28 @@ export default function App() {
}, [state.items]);
// send wrapper: commits any pending optimistic rewind before sending.
const commitThenSend = useCallback(async (sourceTabId: string, displayText: string, submitText?: string, structured?: StructuredInvocationSubmit) => {
const commitThenSend = useCallback(async (
sourceTabId: string,
displayText: string,
submitText?: string,
structured?: StructuredInvocationSubmit,
initialGoal?: {
goal: string;
target: WorkbenchTargetToken;
collaborationMode: CollaborationMode;
toolApprovalMode: ToolApprovalMode;
},
) => {
const sourceTab = tabMetas.find((tab) => tab.id === sourceTabId);
if (!sourceTab) throw new Error(t("composer.workspaceStarting"));
if (sourceTab.readOnly) throw new Error(t("composer.readOnlyChannel"));
if (sourceTab.ready === false) throw new Error(t("composer.workspaceStarting"));
if (
sourceTab.ready !== true ||
(sourceTab.runtime && sourceTab.runtime.phase !== "ready") ||
sourceTab.startupErr
) {
throw new Error(sourceTab.runtime?.issue?.message || sourceTab.startupErr || t("composer.workspaceStarting"));
}
const rs = rewindStatesByTabRef.current[sourceTabId];
if (rs) {
setRewindStateForTab(sourceTabId, null);
@@ -2898,7 +3137,7 @@ export default function App() {
setProjectRevision((v) => v + 1);
}
}
await sendToTab(sourceTabId, displayText, submitText, undefined, structured);
await sendToTab(sourceTabId, displayText, submitText, undefined, structured, initialGoal);
}, [rewindForTab, sendToTab, setRewindCommittingForTab, setRewindStateForTab, t, tabMetas]);
const handleTranscriptPrompt = useCallback((text: string) => {
@@ -2928,7 +3167,7 @@ export default function App() {
// Fork still goes through the controller (not optimistic).
rewindForTab(sourceTabId, turn, scope).then((ok) => {
if (!ok) return;
refreshTabMetas();
void refreshTabMetas(undefined, { afterMutation: true });
setProjectRevision((v) => v + 1);
});
return;
@@ -3062,21 +3301,24 @@ export default function App() {
const navigationRunningRef = useRef(false);
const navigationPendingRef = useRef<PendingDesktopNavigationRequest | null>(null);
const runNavigationRequest = useCallback(async (request: PendingDesktopNavigationRequest) => {
const latest = () => request.seq === navigationSeqRef.current;
const latest = () => request.seq === navigationSeqRef.current && isNavigationIntentCurrent(request.navigationIntentSeq);
if (!latest()) return;
const refreshLatestTabMetas = async (): Promise<TabMeta[]> => {
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
if (latest()) setTabMetas(tabs);
return tabs;
};
const openTopicTarget = async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<TabMeta> => {
if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || "");
if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath);
if (scope === "global") return openGlobalTab(topicId);
return openProjectTab(workspaceRoot, topicId);
if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || "", request.navigationIntentSeq);
if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath, request.navigationIntentSeq);
if (scope === "global") return openGlobalTab(topicId, request.navigationIntentSeq);
return openProjectTab(workspaceRoot, topicId, request.navigationIntentSeq);
};
const openBlankTarget = async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const root = scope === "project" ? workspaceRoot : "";
return singleSurfaceLayout ? ensureBlankSurface(scope, root) : ensureBlankTab(scope, root);
return singleSurfaceLayout
? ensureBlankSurface(scope, root, request.navigationIntentSeq)
: ensureBlankTab(scope, root, request.navigationIntentSeq);
};
try {
@@ -3103,7 +3345,7 @@ export default function App() {
}
if (request.kind === "delivery-worktree") {
const result = await createDeliveryWorktree(request.workspaceRoot);
const result = await createDeliveryWorktree(request.workspaceRoot, request.navigationIntentSeq);
if (!latest()) return;
seedActiveTabMeta(result.tab);
setProjectRevision((value) => value + 1);
@@ -3132,11 +3374,11 @@ export default function App() {
if (connection.sessionSource === "auto" && target.kind === "path") {
openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot);
if (!latest()) return;
await openChannelSession(target.value, openedTab.id);
await openChannelSession(target.value, openedTab.id, request.navigationIntentSeq);
} else if (target.kind === "path") {
openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot);
if (!latest()) return;
await resumeSession(target.value, openedTab.id);
await resumeSession(target.value, openedTab.id, request.navigationIntentSeq);
} else {
openedTab = await openTopicTarget(connection.scope, connection.workspaceRoot, target.value);
}
@@ -3156,7 +3398,7 @@ export default function App() {
if (isChannelSession(session)) {
targetTab = await openBlankTarget(scope === "project" ? "project" : "global", scope === "project" ? session.workspaceRoot || "" : "");
if (!latest()) return;
await openChannelSession(session.path, targetTab.id);
await openChannelSession(session.path, targetTab.id, request.navigationIntentSeq);
} else if (scope === "project" && session.workspaceRoot && session.topicId) {
targetTab = await openTopicTarget("project", session.workspaceRoot, session.topicId, session.path);
} else if (scope === "global" && session.topicId) {
@@ -3202,19 +3444,19 @@ export default function App() {
showToast(err?.message || String(err));
}
}
}, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
}, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
const enqueueNavigation = useCallback((input: DesktopNavigationInput): Promise<void> => {
const enqueueNavigation = useCallback((input: DesktopNavigationIntent): Promise<void> => {
// Invalidate any in-flight activation's stale apply at ENQUEUE time. The
// queue serializes requests, so a click made while another request runs
// only advances the controller's navigation epoch when it eventually
// starts — too late: the running request's ActivateTopic would resolve,
// pass the controller-local guard, flip the visible tab, and prune the
// newer surface's cached state (#6613 review).
noteNavigationIntent();
const navigationIntentSeq = noteNavigationIntent();
return enqueueNavigationRequest(
{ seqRef: navigationSeqRef, runningRef: navigationRunningRef, pendingRef: navigationPendingRef },
input,
{ ...input, navigationIntentSeq } as DesktopNavigationInput,
runNavigationRequest,
);
}, [noteNavigationIntent, runNavigationRequest]);
@@ -3225,7 +3467,7 @@ export default function App() {
useEffect(() => onSessionRecovered(() => {
setProjectRevision((value) => value + 1);
void refreshTabMetas();
void refreshTabMetas(undefined, { afterMutation: true });
}), [refreshTabMetas]);
const handleNewTab = useCallback(async () => {
@@ -3326,6 +3568,7 @@ export default function App() {
},
{ id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: <Brain size={15} />, compact: true, keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") },
{ id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: <Cpu size={15} />, compact: true, keywords: ["model", "模型"], run: () => setSettingsTarget("models") },
{ id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: <TerminalSquare size={15} />, compact: true, keywords: ["terminal", "shell", "终端"], run: () => openRightDockMode("terminal") },
];
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayLabel = (ms: number) => {
@@ -3364,7 +3607,7 @@ export default function App() {
};
});
return [...cmds, ...remoteItems, ...sessionItems];
}, [t, paletteSessions, remoteHosts, remoteStatuses, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace]);
}, [t, paletteSessions, remoteHosts, remoteStatuses, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace, openRightDockMode]);
// Delete / rename act on disk, then re-fetch so the panel reflects the change.
const onDeleteSession = useCallback(
async (path: string) => {
@@ -3461,14 +3704,14 @@ export default function App() {
const picked = path === undefined ? await pickWorkspace() : await switchWorkspace(path);
if (picked) {
setProjectRevision((value) => value + 1);
await refreshTabMetas();
await refreshTabMetas(undefined, { afterMutation: true });
}
return picked;
}, [pickWorkspace, switchWorkspace, refreshTabMetas]);
const refreshProjectsAndTabs = useCallback(async () => {
setProjectRevision((value) => value + 1);
const tabs = await refreshTabMetas();
const tabs = await refreshTabMetas(undefined, { afterMutation: true });
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
await syncActiveTab(false);
}
@@ -3601,6 +3844,7 @@ export default function App() {
sidebarCollapsed ? "layout--sidebar-collapsed" : "",
sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
workspacePanelGridOpen ? "layout--workspace-open" : "",
workspacePanelGridOpen && rightDockMode === "terminal" ? "layout--terminal-open" : "",
workspacePanelOpen && workspacePanelMaximized ? "layout--workspace-maximized" : "",
workspacePanelResizing ? "layout--resizing layout--workspace-resizing" : "",
]
@@ -4040,6 +4284,19 @@ export default function App() {
</button>
</Tooltip>
)}
{!sidebarImDetailConnection && (
<Tooltip label={t("rightDock.terminal")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("rightDock.terminal")}
aria-pressed={workspacePanelRenderable && rightDockMode === "terminal"}
onClick={() => openRightDockMode("terminal")}
>
<TerminalSquare size={14} />
</button>
</Tooltip>
)}
{!sidebarImDetailConnection && activeTab?.scope === "project" && (
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
@@ -4135,6 +4392,7 @@ export default function App() {
<Transcript
items={displayItems}
live={state.live}
liveStore={liveStore}
tabId={activeTabId}
footerHeight={footerHeight}
onPrompt={handleTranscriptPrompt}
@@ -4198,7 +4456,7 @@ export default function App() {
tabId={activeTabId}
workspaceScopeKey={workspaceScopeKey}
insertRequest={activePlanRevisionInsertRequest}
onRevisionActiveChange={(active) => setWorkspaceInsertTarget(active ? "planRevision" : "composer")}
onRevisionActiveChange={handleRevisionActiveChange}
onAnswer={async (allow, session, persist) => {
// Approving an exit_plan_mode plan leaves plan mode; await the
// mode switch before sending the approval so the controller
@@ -4270,11 +4528,11 @@ export default function App() {
onCancel={cancel}
onCycleMode={cycleMode}
onSetMode={applyMode}
onSetCollaborationMode={applyCollaborationMode}
onSetCollaborationMode={setCollaborationModeFromUi}
onSetToolApprovalMode={applyToolApprovalMode}
onToggleYoloApprovalMode={toggleYoloApprovalMode}
onClearGoal={() => applyGoal("")}
onSwitchModel={switchModel}
onClearGoal={clearGoalFromUi}
onSwitchModel={switchModelFromUi}
onSetEffort={setEffort}
onSetTokenMode={applyTokenMode}
insertRequest={composerInsertRequest}
@@ -4301,6 +4559,7 @@ export default function App() {
showContextWindowRing={sidebarCreation}
context={state.context}
turnCost={state.turnCost}
currency={state.sessionCurrency}
cacheHitTokens={state.usage?.cacheHitTokens}
cacheMissTokens={state.usage?.cacheMissTokens}
balance={state.balance}
@@ -4412,6 +4671,16 @@ export default function App() {
<span className="workbench-dock__tab-label">{t("rightDock.remote")}</span>
</button>
)}
<button
type="button"
role="tab"
aria-selected={rightDockMode === "terminal"}
className={`workbench-dock__tab${rightDockMode === "terminal" ? " workbench-dock__tab--active" : ""}`}
onClick={() => openRightDockMode("terminal")}
>
<TerminalSquare size={13} />
<span className="workbench-dock__tab-label">{t("rightDock.terminal")}</span>
</button>
</div>
</div>
<div className="workbench-dock__body">
@@ -4419,6 +4688,16 @@ export default function App() {
<Suspense fallback={null}>
<RemotePanel onClose={() => setWorkspacePanel(false)} />
</Suspense>
) : rightDockMode === "terminal" ? (
<Suspense fallback={<div className="terminal-empty"><span className="terminal-empty__spinner" />{t("terminal.loading")}</div>}>
<TerminalPanel
tabId={activeTabId ?? ""}
cwd={state.meta?.cwd}
readOnly={Boolean(activeTab?.readOnly)}
onClose={() => setWorkspacePanel(false)}
onAddOutput={(sessionId) => void addTerminalOutputToComposer(sessionId)}
/>
</Suspense>
) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? (
<ContextPanel
tabId={activeTabId}
@@ -4441,6 +4720,8 @@ export default function App() {
tabId={activeTabId}
cwd={state.meta?.cwd}
workspaceScopeKey={workspaceScopeKey}
workspaceMemoryKey={workspaceTreeMemoryKey}
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
maximized={workspacePanelMaximized}
panelWidth={workspacePanelRenderWidth}
onClose={() => setWorkspacePanel(false)}
@@ -4453,6 +4734,7 @@ export default function App() {
onAddCodeToChat={addWorkspaceCodeToComposer}
onRequestPanelWidth={ensureWorkspacePanelWidth}
onFileTreeRefresh={refreshComposerFileRefs}
onOpenInTerminal={openTerminalForPath}
refreshKey={dockRefreshKey}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
showViewTabs={false}
@@ -298,8 +298,16 @@ await waitFor("queued last click applies once it runs", () => controller?.active
// enqueue time — the queue-based scenario above only proves the mechanism.
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
ok(
/const enqueueNavigation = useCallback\(\(input: DesktopNavigationInput\)[\s\S]{0,700}?noteNavigationIntent\(\);[\s\S]{0,700}?enqueueNavigationRequest\(/.test(appSource),
"App.enqueueNavigation calls noteNavigationIntent() before enqueueNavigationRequest",
/const enqueueNavigation = useCallback\(\(input: DesktopNavigationIntent\)[\s\S]{0,900}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,900}?enqueueNavigationRequest\(/.test(appSource),
"App.enqueueNavigation captures a shared navigation intent before enqueueNavigationRequest",
);
ok(
/const enqueueTabSwitch = useCallback\([\s\S]{0,1400}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,1400}?switchTab\(request\.tabId, request\.optimisticTab, request\.navigationIntentSeq\)/.test(appSource),
"App.enqueueTabSwitch invalidates older navigation at enqueue time and forwards the shared intent",
);
ok(
/const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\)/.test(appSource),
"App navigation results require both queue ownership and the shared navigation intent",
);
console.log(`\n${passed} passed, ${failed} failed`);
@@ -3,6 +3,8 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, tabMetaFallbackDelay } from "../lib/tabMetaRefresh";
import type { TabMeta } from "../lib/types";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
@@ -30,6 +32,16 @@ function ok(value: unknown, label: string) {
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function matchingBlocks(selector: string): string[] {
const blocks: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
@@ -55,6 +67,179 @@ function finalDeclaration(selector: string, property: string): string | undefine
console.log("\napp chrome tabs");
const tabMeta = (overrides: Partial<TabMeta> = {}): TabMeta => ({
id: "tab-1",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
topicId: "topic-1",
topicTitle: "Topic",
label: "model",
ready: true,
running: false,
cancellable: false,
mode: "normal",
active: true,
cwd: "/repo",
...overrides,
});
ok(sameTabMetaLists([tabMeta()], [tabMeta()]), "identical tab metadata suppresses redundant state writes");
ok(!sameTabMetaLists([tabMeta()], [tabMeta({ running: true })]), "runtime tab changes still invalidate metadata state");
ok(tabMetaFallbackDelay("visible") === 15_000, "visible tab metadata fallback runs at low frequency");
ok(tabMetaFallbackDelay("hidden") === 60_000, "hidden tab metadata fallback backs off further");
ok(shouldRefreshTabMetaForEvent("turn_started"), "turn start refreshes tab runtime metadata immediately");
ok(shouldRefreshTabMetaForEvent("approval_request"), "approval prompts refresh tab runtime metadata immediately");
ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger tab-list requests");
{
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
const first = deferred<TabMeta[]>();
const second = deferred<TabMeta[]>();
let loads = 0;
const firstRefresh = coordinator.run(() => {
loads += 1;
return first.promise;
});
const secondRefresh = coordinator.run(() => {
loads += 1;
return second.promise;
});
const saturatedRefresh = coordinator.run(() => {
loads += 1;
return Promise.resolve([]);
});
await Promise.resolve();
ok(loads === 2, "tab metadata refresh caps outstanding backend calls");
const latestTabs = [tabMeta({ id: "tab-latest" })];
second.resolve(latestTabs);
const saturatedResult = await saturatedRefresh;
ok(saturatedResult.coalesced, "saturated tab metadata refresh joins the newest request");
ok(saturatedResult.value === latestTabs, "saturated tab metadata refresh returns authoritative tabs instead of an empty sentinel");
ok(saturatedResult.latest, "coalesced newest tab metadata remains eligible to update state");
first.resolve([tabMeta({ id: "tab-stale" })]);
const firstResult = await firstRefresh;
await secondRefresh;
ok(!firstResult.latest, "an older tab metadata response cannot replace a newer snapshot");
}
{
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
const preMutationA = deferred<TabMeta[]>();
const preMutationB = deferred<TabMeta[]>();
const postMutation = deferred<TabMeta[]>();
let loads = 0;
const loadValues: TabMeta[][] = [
[tabMeta({ id: "pre-a" })],
[tabMeta({ id: "pre-b" })],
[tabMeta({ id: "post-mutation" })],
];
const loadPromises = [preMutationA.promise, preMutationB.promise, postMutation.promise];
const firstRefresh = coordinator.run(() => {
const index = loads;
loads += 1;
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
});
const secondRefresh = coordinator.run(() => {
const index = loads;
loads += 1;
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
});
await Promise.resolve();
ok(loads === 2, "pre-mutation tab metadata fills both in-flight slots");
const mutationRefresh = coordinator.run(
() => {
const index = loads;
loads += 1;
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
},
{ invalidate: true },
);
await Promise.resolve();
ok(loads === 2, "post-mutation refresh does not start until an in-flight slot frees");
preMutationA.resolve(loadValues[0]);
const firstResult = await firstRefresh;
await Promise.resolve();
await Promise.resolve();
ok(loads === 3, "post-mutation refresh starts as a trailing load after a slot frees");
ok(!firstResult.latest, "pre-mutation snapshot is not authoritative after invalidate");
const postTabs = loadValues[2];
postMutation.resolve(postTabs);
const mutationResult = await mutationRefresh;
ok(!mutationResult.coalesced, "post-mutation refresh does not join a pre-mutation request");
ok(mutationResult.value === postTabs, "post-mutation refresh returns the mutation-after snapshot");
ok(mutationResult.latest, "post-mutation trailing refresh remains eligible to update state");
preMutationB.resolve(loadValues[1]);
const secondResult = await secondRefresh;
ok(!secondResult.latest, "pre-mutation coalesced request cannot overwrite post-mutation state");
}
{
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(1);
const preMutation = deferred<TabMeta[]>();
const latestPostMutation = deferred<TabMeta[]>();
const started: string[] = [];
const preMutationRefresh = coordinator.run(() => {
started.push("pre");
return preMutation.promise;
});
await Promise.resolve();
const firstMutationRefresh = coordinator.run(
() => {
started.push("first-mutation");
return Promise.resolve([tabMeta({ id: "first-mutation" })]);
},
{ invalidate: true },
);
const latestMutationRefresh = coordinator.run(
() => {
started.push("latest-mutation");
return latestPostMutation.promise;
},
{ invalidate: true },
);
preMutation.resolve([tabMeta({ id: "pre" })]);
const preMutationResult = await preMutationRefresh;
await Promise.resolve();
await Promise.resolve();
ok(started.join(",") === "pre,latest-mutation", "queued invalidations retain only the latest trailing load");
ok(!preMutationResult.latest, "queued invalidations fence the pre-mutation load");
const latestTabs = [tabMeta({ id: "latest-mutation" })];
latestPostMutation.resolve(latestTabs);
const [firstMutationResult, latestMutationResult] = await Promise.all([firstMutationRefresh, latestMutationRefresh]);
ok(firstMutationResult.value === latestTabs, "an older queued mutation waits for the latest post-mutation snapshot");
ok(latestMutationResult.value === latestTabs, "the latest queued mutation receives its post-mutation snapshot");
ok(firstMutationResult.latest && latestMutationResult.latest, "the shared trailing snapshot remains authoritative");
}
ok(
!appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") &&
appSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') &&
appSource.includes("createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT)") &&
appSource.includes("void refreshTabMetas();\n schedule();"),
"tab metadata refresh is event-driven with a visibility-aware fallback",
);
ok(
appSource.includes("refreshTabMetas(undefined, { afterMutation: true })") &&
appSource.includes("{ afterMutation: true }") &&
appSource.includes("if (shouldRefreshTabMetaForEvent(e.kind)) {") &&
appSource.includes("void refreshTabMetas(undefined, { afterMutation: true });") &&
/await refreshTabMetas\(\s*\(\) => isNavigationIntentCurrent\(request\.navigationIntentSeq\),\s*\{\s*afterMutation:\s*true\s*\},?\s*\)/.test(appSource),
"tab lifecycle events and explicit mutations force a post-mutation trailing metadata refresh",
);
ok(
/import \{ TabBar \} from "\.\/TabBar";/.test(appChromeSource),
"AppChrome keeps the classic top session tab strip implementation",
@@ -245,7 +430,7 @@ ok(
);
ok(
/const controllerReady = state\.meta\?\.ready === true && !state\.backendActivationPending && !runtimeTransitioning;/.test(appSource) &&
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
/submitDisabled=\{!controllerReady\}/.test(appSource),
@@ -261,7 +446,7 @@ ok(
ok(
/commitThenSendRef\.current\(sourceTabId, trimmed, submitText\.trim\(\), structured\)/.test(appSource) &&
/sendToTab\(sourceTabId, displayText, submitText, undefined, structured\)/.test(appSource) &&
/sendToTab\(sourceTabId, displayText, submitText, undefined, structured, initialGoal\)/.test(appSource) &&
/onSteer=\{handleSteer\}/.test(appSource) &&
/composerInsertRequestsByTab\[activeTabId\]/.test(appSource) &&
/consumedInsertIdByDraftRef\.current\[draftKey\]/.test(composerSource),
@@ -318,11 +503,11 @@ ok(
/const navigationRunningRef = useRef\(false\);/.test(appSource) &&
/const navigationPendingRef = useRef<PendingDesktopNavigationRequest \| null>\(null\);/.test(appSource) &&
/const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
/const latest = \(\) => request\.seq === navigationSeqRef\.current;/.test(appSource) &&
/return activateTopic\(scope, workspaceRoot, topicId/.test(appSource) &&
/return openTopicSession\(scope, workspaceRoot, topicId/.test(appSource) &&
/return openGlobalTab\(topicId\)/.test(appSource) &&
/return openProjectTab\(workspaceRoot, topicId\)/.test(appSource) &&
/const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\);/.test(appSource) &&
/return activateTopic\(scope, workspaceRoot, topicId, sessionPath \|\| "", request\.navigationIntentSeq\)/.test(appSource) &&
/return openTopicSession\(scope, workspaceRoot, topicId, sessionPath, request\.navigationIntentSeq\)/.test(appSource) &&
/return openGlobalTab\(topicId, request\.navigationIntentSeq\)/.test(appSource) &&
/return openProjectTab\(workspaceRoot, topicId, request\.navigationIntentSeq\)/.test(appSource) &&
/enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
!/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
/const refreshLatestTabMetas = async \(\): Promise<TabMeta\[]> => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { activeFileReferenceToken, pickInlineFileReference } from "../components/FileReferenceMenu";
import { LocaleProvider } from "../lib/i18n";
import { LocaleProvider, preloadDetectedLocale } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WireApproval } from "../lib/types";
@@ -95,6 +95,7 @@ function mockApp(methods: Partial<AppBindings>) {
}
async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]> = {}) {
await preloadDetectedLocale();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
@@ -328,6 +329,44 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderApproval();
await clickImmediateAction("Revise plan");
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("plan revision textarea did not render");
ok(textarea === document.activeElement, "opening plan revision focuses its textarea once");
const transcriptText = document.createElement("p");
transcriptText.tabIndex = -1;
transcriptText.textContent = "copy this plan text";
document.body.appendChild(transcriptText);
transcriptText.focus();
const range = document.createRange();
range.selectNodeContents(transcriptText);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// App refreshes tab metadata periodically; emulate callback churn from a parent rerender.
await rerender({ onRevisionActiveChange: () => undefined });
ok(document.activeElement === transcriptText, "parent rerender does not return focus to plan revision");
eq(document.getSelection()?.toString(), "copy this plan text", "parent rerender preserves transcript text selection");
transcriptText.remove();
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -65,7 +65,7 @@ ok(
const errorStart = snapshotBreadcrumbs().length;
now = 100;
try {
await app.CheckUpdate();
await app.CheckUpdate("");
} catch {
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
}
@@ -22,6 +22,8 @@ const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
const mainSource = readFileSync(resolve(here, "../main.tsx"), "utf8");
const stylesSource = readFileSync(resolve(here, "../styles.css"), "utf8");
console.log("\nbundle contract");
@@ -97,6 +99,20 @@ ok(
markdownSource.includes('import("./MarkdownRenderer")'),
"Markdown wrapper loads markdown renderer on demand",
);
ok(
!/import\s+\{\s*zh\s*\}\s+from\s+["']\.\.\/locales\/zh["']/.test(i18nSource) &&
!/import\s+\{\s*zhTW\s*\}\s+from\s+["']\.\.\/locales\/zh-TW["']/.test(i18nSource),
"i18n keeps Chinese dictionaries out of the unconditional initial chunk",
);
ok(
i18nSource.includes('import("../locales/zh")') &&
i18nSource.includes('import("../locales/zh-TW")'),
"i18n loads Chinese dictionaries on demand",
);
ok(
mainSource.includes("await preloadDetectedLocale()"),
"main preloads the detected locale before mounting React",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MCPServersSettingsPage, PluginsSettingsPage, failureKind, mcpServerDraftJSON, parseMCPServerJSON, summarizeServerError, withExplicitMCPClears } from "../components/CapabilitiesPanel";
import { MCPServersSettingsPage, PluginsSettingsPage, failureKind, mcpServerDraftJSON, parseMCPQuickDefinition, parseMCPServerJSON, summarizeServerError, withExplicitMCPClears } from "../components/CapabilitiesPanel";
import { slashCommandGroup, slashCommandKindTag, sortSlashCommandsForMenu } from "../components/SlashMenu";
import { selectToolsOnFirstCustomUse } from "../components/SubagentsPanel";
import type { AppBindings } from "../lib/bridge";
@@ -81,6 +81,12 @@ function ok(value: unknown, message: string) {
}];
return 1;
},
InstallMCPServer: async (input) => {
const app = window.go?.main?.App;
if (!app) throw new Error("missing App bindings");
const toolCount = await app.AddMCPServer(input);
return { name: input.name, state: "ready", toolCount, action: "none", message: "ready" };
},
} as Partial<AppBindings> as AppBindings,
},
};
@@ -125,6 +131,19 @@ function ok(value: unknown, message: string) {
dom.window.close();
}
const quickCommand = parseMCPQuickDefinition("npx -y chrome-devtools-mcp@latest");
ok(quickCommand.name === "chrome-devtools-mcp" && quickCommand.transport === "stdio", "quick install should derive a stable name and stdio transport from one command");
const quickFilesystem = parseMCPQuickDefinition('npx -y @modelcontextprotocol/server-filesystem "/srv/shared data"');
ok(quickFilesystem.name === "server-filesystem", "quick install name should come from the launcher package, not a trailing server argument");
const quickPythonModule = parseMCPQuickDefinition("python -m mcp_server_time --local-timezone=UTC");
ok(quickPythonModule.name === "mcp-server-time", "python module quick install should derive its name from the module");
const quickURL = parseMCPQuickDefinition("https://mcp.linear.app/mcp");
ok(quickURL.name === "mcp" && quickURL.transport === "http", "quick install should derive HTTP transport from a URL");
const quickJSON = parseMCPQuickDefinition(JSON.stringify({ custom: { command: "uvx", args: ["demo-mcp"] } }));
ok(quickJSON.name === "custom" && quickJSON.args[0] === "demo-mcp", "quick install should preserve advanced JSON definitions");
const completeMCPJSON = JSON.stringify({
admin: {
type: "streamable-http",
@@ -267,7 +286,8 @@ const failed = mcpServerLifecycleActions({ ...server("failed"), runtimeState: "i
ok(failed.showRetryInRow, "failed server row should expose retry");
ok(mcpServerRetryableFromAvailableList(server("initializing")), "connecting server should be included in available-list retry all");
ok(mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic" }), "automatic idle server should be included in available-list retry all");
ok(!mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic" }), "healthy on-demand server should not be included in retry all");
ok(mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic", action: "retry" }), "explicit retry action should remain available for an idle server");
ok(!mcpServerRetryableFromAvailableList(server("connected")), "connected server should be excluded from available-list retry all");
ok(!mcpServerRetryableFromAvailableList({ ...server("disabled"), startIntent: "off" }), "disabled server should be excluded from available-list retry all");
ok(!mcpServerRetryableFromAvailableList({ ...server("failed"), runtimeState: "issue" }), "failed server is handled by the failure banner retry all");
@@ -336,17 +356,6 @@ function setInputValue(input: HTMLInputElement, value: string) {
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const win = textarea.ownerDocument.defaultView;
const previous = textarea.value;
const setter = Object.getOwnPropertyDescriptor((win?.HTMLTextAreaElement ?? HTMLTextAreaElement).prototype, "value")?.set;
setter?.call(textarea, value);
(textarea as HTMLTextAreaElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous);
const eventCtor = win?.Event ?? Event;
textarea.dispatchEvent(new eventCtor("input", { bubbles: true }));
textarea.dispatchEvent(new eventCtor("change", { bubbles: true }));
}
ok(
slashCommandKindTag({ name: "pwf:plan", description: "Plugin planning prompt.", kind: "custom", plugin: "pwf" }, t) === "plugin · pwf",
"slash menu identifies the canonical plugin command source",
@@ -473,24 +482,35 @@ console.log("capabilities panel MCP actions");
active: true,
cwd: "/tmp/reasonix-test",
}];
let projectAuthorizationCount = 0;
let servers: ServerView[] = [{
name: "github",
transport: "stdio",
status: "failed",
runtimeState: "issue",
status: "connected",
runtimeState: "ready",
configured: true,
source: "project",
configSource: "reasonix.toml",
autoStart: true,
tools: 0,
tools: 3,
prompts: 0,
resources: 0,
error: "project-provided MCP server is blocked until the user authorizes it",
requiresLaunchApproval: true,
toolList: [
{ name: "issue_read", description: "Read issues.", readOnlyHint: true },
{ name: "issue_write", description: "Write issues." },
{ name: "wipe", description: "Delete data.", destructiveHint: true },
],
}, {
name: "linear",
transport: "http",
status: "connected",
runtimeState: "ready",
configured: true,
source: "user",
autoStart: true,
tools: 1,
prompts: 0,
resources: 0,
toolList: [{ name: "get_issue", description: "Read an issue.", readOnlyHint: true }],
}];
window.go = {
main: {
@@ -498,16 +518,6 @@ console.log("capabilities panel MCP actions");
Meta: async () => meta,
ListTabs: async () => tabs,
MCPServers: async () => servers,
AuthorizeAndConnectMCPServer: async () => {
projectAuthorizationCount += 1;
servers = servers.map((item) => ({
...item,
status: "connected",
runtimeState: "ready",
requiresLaunchApproval: false,
error: "",
}));
},
} as Partial<AppBindings> as AppBindings,
},
};
@@ -524,16 +534,17 @@ console.log("capabilities panel MCP actions");
await flush();
});
};
await waitFor("project MCP authorization action", () => Boolean(findButton("Authorize and connect")));
ok(!findButton("Review changes"), "project MCP launch approval has no separate change-review workflow");
await waitFor("trusted project MCP", () => Boolean(document.querySelector('[data-status="connected"]')));
ok(document.body.textContent?.includes("This project"), "project MCP is grouped under This project");
ok(document.body.textContent?.includes("Global MCP"), "user-installed MCP is grouped by its global scope");
ok(document.body.textContent?.includes("Install once and use automatically in every Reasonix project."), "global MCP explains its cross-project availability");
ok(document.body.textContent?.includes("Project"), "project MCP row shows a project source badge");
ok(document.body.textContent?.includes("Declared by this project and available automatically."), "project MCP explains zero-confirmation availability");
ok(!findButton("Install and use"), "trusted project MCP has no install confirmation");
ok(!findButton("Authorize and connect"), "trusted project MCP has no authorization action");
ok(!findButton("Review changes"), "project MCP has no separate change-review workflow");
ok(!findButton("Refresh catalog"), "catalog maintenance is not part of the normal MCP workflow");
ok(!document.querySelector('[role="dialog"]'), "project authorization does not open a second modal");
await act(async () => {
findButton("Authorize and connect")?.click();
await flush();
});
await waitFor("durable project launch authorization", () => projectAuthorizationCount === 1 && Boolean(document.querySelector('[data-status="connected"]')));
ok(!document.querySelector('[role="dialog"]'), "project confirmation connects directly without a second modal");
ok(!document.querySelector('[role="dialog"]'), "project MCP does not open a confirmation modal");
servers = servers.map((item) => ({
...item,
@@ -567,13 +578,14 @@ console.log("capabilities panel MCP actions");
requiresLaunchApproval: false,
}));
await refreshStatus();
await waitFor("authorized project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
await waitFor("trusted project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
await act(async () => {
(document.querySelector(".cap-mcp-list-row__main") as HTMLButtonElement | null)?.click();
await flush();
});
await waitFor("connected project server detail", () => Boolean(document.querySelector(".cap-mcp-subpage")));
ok(!findButton("Review changes"), "an authorized connected project server does not show a change alarm");
ok(document.body.textContent?.includes("Current project · reasonix.toml"), "project MCP details show their configuration source");
ok(!findButton("Review changes"), "a trusted connected project server does not show a change alarm");
ok(!findButton("Revoke trust"), "normal MCP details do not expose a second authorization-management workflow");
await act(async () => {
@@ -787,6 +799,23 @@ console.log("capabilities panel MCP actions");
}];
return 0;
},
InstallMCPServer: async (input: MCPServerInput) => {
addedInput = input;
servers = [...servers, {
name: input.name,
transport: input.transport,
status: "connected",
configured: true,
autoStart: true,
command: input.command,
args: input.args,
url: input.url,
tools: 0,
prompts: 0,
resources: 0,
}];
return { name: input.name, state: "ready", toolCount: 0, action: "none", message: "ready" };
},
} as Partial<AppBindings> as AppBindings,
},
};
@@ -812,94 +841,18 @@ console.log("capabilities panel MCP actions");
addServer.click();
await flush();
});
const advanced = findButton("Advanced options");
if (!advanced) throw new Error("missing Advanced options button");
ok(advanced.getAttribute("aria-expanded") === "false", "new server advanced options are collapsed by default");
ok(!document.querySelector(".cap-mcp-advanced__body"), "collapsed advanced options keep environment fields out of the initial form");
const jsonMode = findButton("JSON");
if (!jsonMode) throw new Error("missing JSON editor mode");
const quickInstall = findButton("Quick install");
const manualSetup = findButton("Manual setup");
ok(quickInstall?.getAttribute("aria-selected") === "true" && Boolean(manualSetup) && Boolean(findButton("JSON")), "new server install defaults to quick install while keeping manual and JSON configuration in the same editor");
const definitionEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-quick__input");
if (!definitionEditor) throw new Error("missing quick MCP install input");
ok(definitionEditor.placeholder.includes("chrome-devtools-mcp@latest"), "the default install path asks only for a command, URL, or JSON definition");
await act(async () => {
jsonMode.click();
manualSetup?.click();
await flush();
});
const initialJSONEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-json-editor__input");
if (!initialJSONEditor) throw new Error("missing MCP JSON editor");
await act(async () => {
findButton("Form")?.click();
await flush();
});
ok(Boolean(document.querySelector(".cap-mcp-form-grid")), "the untouched empty MCP template can switch back to the form editor");
ok(document.querySelector<HTMLInputElement>(".cap-mcp-field--name input")?.value === "", "returning from the untouched template preserves the empty server name");
await act(async () => {
findButton("JSON")?.click();
await flush();
});
const incompleteJSONEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-json-editor__input");
if (!incompleteJSONEditor) throw new Error("missing MCP JSON editor after returning from the empty form");
await act(async () => {
setTextareaValue(incompleteJSONEditor, JSON.stringify({ "yakit-next": { type: "stdio", command: "" } }, null, 2));
await flush();
});
await act(async () => {
findButton("Form")?.click();
await flush();
});
ok(Boolean(document.querySelector(".cap-mcp-form-grid")), "an incomplete but structured MCP draft can switch back to the form editor");
ok(document.querySelector<HTMLInputElement>(".cap-mcp-field--name input")?.value === "yakit-next", "switching an incomplete JSON draft preserves its server name");
await act(async () => {
findButton("JSON")?.click();
await flush();
});
const invalidJSONEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-json-editor__input");
if (!invalidJSONEditor) throw new Error("missing MCP JSON editor after incomplete draft round trip");
await act(async () => {
setTextareaValue(invalidJSONEditor, "{");
await flush();
});
await act(async () => {
findButton("Add and connect")?.click();
await flush();
});
ok(document.querySelector('[role="alert"]')?.textContent?.includes("Enter valid JSON") ?? false, "invalid MCP JSON shows a focused validation error");
ok(!addedInput, "invalid MCP JSON does not call AddMCPServer");
const validJSON = JSON.stringify({
mcpServers: {
"yakit-next": {
command: "npx",
args: ["-y", "@yaklang/mcp", "hello world"],
env: { TOKEN: "test-token" },
},
},
}, null, 2);
await act(async () => {
setTextareaValue(invalidJSONEditor, validJSON);
await flush();
});
await act(async () => {
findButton("Form")?.click();
await flush();
});
ok(Boolean(document.querySelector(".cap-mcp-form-grid")), "valid MCP JSON can switch back to the form editor");
await act(async () => {
findButton("JSON")?.click();
await flush();
});
const roundTripJSONEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-json-editor__input");
if (!roundTripJSONEditor) throw new Error("missing MCP JSON editor after round trip");
const roundTripped = JSON.parse(roundTripJSONEditor.value) as Record<string, { args?: string[] }>;
ok(roundTripped["yakit-next"]?.args?.[2] === "hello world", "form and JSON mode round trip preserves structured MCP arguments");
await act(async () => {
findButton("Add and connect")?.click();
await flush();
});
await waitFor("AddMCPServer call", () => Boolean(addedInput));
ok(addedInput?.name === "yakit-next", "valid MCP JSON passes the server name to AddMCPServer");
ok(addedInput?.command === "npx", "valid MCP JSON keeps the executable separate from its arguments");
ok(addedInput?.args?.[2] === "hello world", "valid MCP JSON passes structured arguments to AddMCPServer");
ok(addedInput?.env?.TOKEN === "test-token", "valid MCP JSON passes environment variables to AddMCPServer");
ok(!document.querySelector('[role="dialog"]'), "adding a user MCP server does not open a second authorization prompt");
ok(Boolean(document.querySelector(".cap-mcp-field--name input")) && Boolean(findButton("Advanced options")), "manual setup restores name, transport, and advanced configuration without leaving the install page");
ok(!addedInput, "opening the quick installer does not mutate MCP state");
await act(async () => {
root.unmount();
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/composer-context-menu-clipboard.test.tsx
//
// Regression coverage for the composer edit context menu's clipboard guards:
// - Undo/redo must expose the same fixed shortcuts as the composer keyboard path.
// - Cut must not delete the selection when every clipboard path failed.
// - Paste must not replace the selection when the clipboard has no text.
// - Shortcut hints must use the platform modifier (Ctrl outside macOS).
@@ -193,7 +194,7 @@ async function openInputMenu(): Promise<HTMLButtonElement[]> {
await flushTimers();
});
const items = Array.from(document.querySelectorAll(".context-menu__item")) as HTMLButtonElement[];
if (items.length !== 4) throw new Error(`expected 4 edit menu items, got ${items.length}`);
if (items.length !== 6) throw new Error(`expected 6 edit menu items, got ${items.length}`);
return items;
}
@@ -235,8 +236,10 @@ async function main() {
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
const hints = items.map((item) => item.querySelector(".context-menu__shortcut")?.textContent ?? "");
eq(hints[0], "Ctrl+X", "cut hint uses the platform modifier");
eq(hints[2], "Ctrl+V", "paste hint uses the platform modifier");
eq(hints[0], "Ctrl+Z", "undo hint uses the platform modifier");
eq(hints[1], "Ctrl+Shift+Z", "redo hint uses the platform modifier");
eq(hints[2], "Ctrl+X", "cut hint uses the platform modifier");
eq(hints[4], "Ctrl+V", "paste hint uses the platform modifier");
ok(hints.every((hint) => !hint.includes("⌘")), "no hardcoded mac glyph on a non-mac platform");
// Close the menu without acting.
await act(async () => {
@@ -245,13 +248,29 @@ async function main() {
});
}
// --- context-menu undo and redo share the programmatic edit history ---
{
await typeAndSelect("menu undo", 9, 9);
const undoItems = await openInputMenu();
ok(!undoItems[0].disabled, "undo is enabled at a programmatic edit boundary");
ok(undoItems[1].disabled, "redo is disabled before an undo");
await clickMenuItem(undoItems[0]);
eq(textarea().value, "hello world", "context-menu undo restores the previous composer text");
const redoItems = await openInputMenu();
ok(redoItems[0].disabled === false, "older programmatic history remains undoable");
ok(!redoItems[1].disabled, "redo is enabled after context-menu undo");
await clickMenuItem(redoItems[1]);
eq(textarea().value, "menu undo", "context-menu redo restores the undone composer edit");
}
// --- cut keeps the draft when every clipboard path fails ---
{
stubClipboard({});
(document as Document & { execCommand?: () => boolean }).execCommand = () => false;
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
await clickMenuItem(items[0]);
await clickMenuItem(items[2]);
eq(textarea().value, "hello world", "failed cut must not delete the selection");
}
@@ -260,7 +279,7 @@ async function main() {
stubClipboard({ writeText: () => Promise.resolve() });
await typeAndSelect("hello world", 0, 6);
const items = await openInputMenu();
await clickMenuItem(items[0]);
await clickMenuItem(items[2]);
eq(textarea().value, "world", "successful cut removes the selected text");
}
@@ -269,7 +288,7 @@ async function main() {
stubClipboard({ readText: () => Promise.resolve("") });
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
await clickMenuItem(items[2]);
await clickMenuItem(items[4]);
eq(textarea().value, "hello world", "empty-clipboard paste must not erase the selection");
}
@@ -278,7 +297,7 @@ async function main() {
stubClipboard({ readText: () => Promise.resolve("bye") });
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
await clickMenuItem(items[2]);
await clickMenuItem(items[4]);
eq(textarea().value, "bye world", "text paste replaces the selection");
}
@@ -6,10 +6,11 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer, composerPickFileEntry } from "../components/Composer";
import { InvocationMetadataContext, UserMessage } from "../components/Message";
import { selectionFromDom } from "../components/RichComposerInput";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
import type { StructuredInvocationSubmit } from "../lib/invocationDisplay";
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
@@ -334,6 +335,142 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "ui-ux-pro-max", description: "Review the interface", kind: "skill" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const { root, calls, rerender } = await renderComposer({ collaborationMode: "goal", goal: "" });
await replaceComposerDraft(rerender, 4199, "/ui-ux-pro-max");
await waitFor("skill menu for the initial goal", () => Boolean(document.querySelector(".slashmenu")));
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render for the initial goal skill");
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
let sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render for the initial goal skill");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.length, 0, "a skill alone cannot become the initial goal");
ok(document.body.textContent?.includes("Enter a goal") === true, "a skill-only initial goal asks for task text");
await replaceComposerDraft(rerender, 4200, "List the existing notes");
sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button disappeared after entering the goal");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send[0], "List the existing notes", "the initial goal keeps its visible task text");
eq(calls.submit[0], "/ui-ux-pro-max List the existing notes", "the initial goal preserves the selected skill");
eq(calls.structured[0]?.input, "List the existing notes", "the initial goal sends structured skill input");
eq(calls.structured[0]?.invocations[0]?.name, "ui-ux-pro-max", "the initial goal submits the selected skill entity");
await replaceComposerDraft(rerender, 4201, "/ui-ux-pro-max List the notes again");
sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render for a pasted skill invocation");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send[1], "List the notes again", "a pasted skill invocation keeps its task as the visible goal");
eq(calls.submit[1], "/ui-ux-pro-max List the notes again", "a pasted skill invocation keeps its slash display");
eq(calls.structured[1]?.input, "List the notes again", "a pasted skill invocation uses structured input");
eq(calls.structured[1]?.invocations[0]?.name, "ui-ux-pro-max", "a pasted skill invocation resolves the selected command");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// Attachment-only first Goal: no text, no skill — attachment refs are valid task context.
const dom = installDom();
mockApp({
SavePastedFile: 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");
await act(async () => {
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
await flushTimers();
});
await waitFor("attachment-only initial goal card", () => document.body.textContent?.includes("notes.txt") === true);
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("send button missing for attachment-only initial goal");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.length, 1, "attachment-only input can become the initial Goal");
ok(
calls.submit[0]?.includes("@.reasonix/attachments/notes.txt") === true,
"attachment-only initial Goal submits the attachment ref",
);
eq(calls.structured[0], undefined, "attachment-only initial Goal is not a structured skill submit");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// Workspace-ref-only first Goal: no text, no skill — workspace refs remain valid task context.
const dom = installDom();
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
OnFileDrop: (cb) => {
droppedCallback = cb;
},
OnFileDropOff: () => {},
};
mockApp({
AttachDropped: async () => ({
kind: "workspace",
path: "src/App.tsx",
isDir: false,
displayPath: "src/App.tsx",
}),
});
const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
if (!droppedCallback) throw new Error("native file drop handler did not register for workspace-ref goal");
await act(async () => {
droppedCallback?.(0, 0, ["/repo/src/App.tsx"]);
await flushTimers();
});
await waitFor("workspace-ref-only initial goal card", () => document.body.textContent?.includes("App.tsx") === true);
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("send button missing for workspace-ref-only initial goal");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.length, 1, "workspace-ref-only input can become the initial Goal");
eq(calls.submit[0], "@src/App.tsx", "workspace-ref-only initial Goal submits the workspace ref");
eq(calls.structured[0], undefined, "workspace-ref-only initial Goal is not a structured skill submit");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -437,6 +574,126 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const command: CommandInfo = {
name: "writing-plans",
description: "Write a plan",
kind: "skill",
};
mockApp({
Commands: async () => [command],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const { root, rerender } = await renderComposer();
await replaceComposerDraft(rerender, 4201, "/writing-plans");
await waitFor("skill menu for paste undo selection", () => Boolean(document.querySelector(".slashmenu")));
const initialTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!initialTextarea) throw new Error("composer textarea did not render for paste undo selection");
await act(async () => {
initialTextarea.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
let token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
const invocationId = token?.dataset.invocationId;
if (!richInput || !token || !invocationId) throw new Error("rich invocation did not render for paste undo selection");
const afterToken = document.createRange();
afterToken.setStartAfter(token);
afterToken.collapse(true);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(afterToken);
await act(async () => {
dispatchPasteText(richInput!, "pasted");
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after paste");
eq(richComposerTaskText(richInput), "pasted", "paste after an invocation inserts on the token's right side");
await act(async () => {
richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await flushTimers();
});
let richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
eq(richMenuItems.length, 6, "rich composer exposes the shared edit context menu");
ok(richMenuItems[0]?.disabled === false, "rich composer context-menu undo is enabled after paste");
ok(richMenuItems[1]?.disabled === true, "rich composer context-menu redo is disabled before undo");
await act(async () => {
richMenuItems[0]?.click();
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after context-menu undo");
eq(richComposerTaskText(richInput), "", "rich composer context-menu undo removes the pasted text");
await act(async () => {
richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await flushTimers();
});
richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
ok(richMenuItems[1]?.disabled === false, "rich composer context-menu redo is enabled after undo");
await act(async () => {
richMenuItems[1]?.click();
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after context-menu redo");
eq(richComposerTaskText(richInput), "pasted", "rich composer context-menu redo restores the pasted text");
const undoPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
richInput!.dispatchEvent(undoPaste);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await flushTimers();
});
eq(undoPaste.defaultPrevented, true, "Ctrl+Z restores the rich-composer paste transaction");
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
if (!richInput || !token) throw new Error("rich invocation disappeared after paste undo");
const restoredInvocation: ComposerInvocation = { id: invocationId, offset: 0, command };
const restoredSelection = selectionFromDom(
richInput,
new Map([[invocationId, restoredInvocation]]),
);
eq(
restoredSelection.ok ? restoredSelection.selection.afterInvocationId : undefined,
invocationId,
"paste undo restores the caret after the invocation token",
);
await act(async () => {
richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Backspace",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
ok(
document.querySelector(".composer-invocation-token") === null,
"Backspace after paste undo removes the invocation on the caret's left",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls } = await renderComposer({
@@ -1252,6 +1509,119 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "writing-plans", description: "Write a plan", kind: "skill", color: "amber" },
{ name: "review", description: "Review the result", kind: "skill" },
{ name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const { root, calls, rerender } = await renderComposer();
const initialText = "请用/writing-plans检查";
await replaceComposerDraft(rerender, 1900, initialText);
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render for middle slash completion");
const slashCaret = "请用/writ".length;
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await flushTimers();
});
await act(async () => {
textarea!.focus();
textarea!.setSelectionRange(slashCaret, slashCaret);
textarea!.dispatchEvent(new window.Event("select", { bubbles: true }));
textarea!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "/", bubbles: true }));
await flushTimers();
});
await waitFor("middle slash command menu", () => Boolean(document.querySelector(".slashmenu")));
await act(async () => {
textarea!.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
let tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
if (!richInput || !tokens?.[0]) throw new Error("middle skill invocation did not render");
eq(richComposerTaskText(richInput), "请用检查", "first middle skill selection preserves surrounding text");
eq(
document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color"),
"#d59a2f",
"middle skill selection keeps its configured color",
);
const afterFirstToken = document.createRange();
afterFirstToken.setStartAfter(tokens[0]);
afterFirstToken.collapse(true);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(afterFirstToken);
await act(async () => {
dispatchPasteText(richInput!, "更多");
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after middle-skill paste");
eq(richComposerTaskText(richInput), "请用更多检查", "paste after a middle skill preserves the entity and suffix");
eq(
richInput.querySelectorAll(".composer-invocation-token").length,
1,
"paste after a middle skill keeps the selected entity",
);
await appendRichComposerInput(richInput, " /review");
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared before the second skill selection");
const queryAtEnd = document.createRange();
queryAtEnd.selectNodeContents(richInput);
queryAtEnd.collapse(false);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(queryAtEnd);
await act(async () => {
richInput!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
await flushTimers();
});
await waitFor("second skill menu at the end", () => Boolean(document.querySelector(".slashmenu")));
await act(async () => {
richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
eq(tokens?.length, 2, "a second skill can be inserted after existing text and an entity");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("send button did not render for middle skill submission");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.structured[0]?.input, "请用更多检查", "middle skills submit the surrounding task text without slash tokens");
eq(
calls.structured[0]?.invocations.map((item) => item.name).join(","),
"writing-plans,review",
"multiple middle/end skills submit in visual order",
);
eq(calls.structured[0]?.invocations[0]?.offset, 2, "first middle skill keeps its text offset");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let commandsCalls = 0;
@@ -1364,8 +1734,42 @@ console.log("\ncomposer goal toggle");
document.activeElement === textareaAfterEntityRemoval,
"removing the last entity hands focus to the textarea that replaces the rich input",
);
const undoEntityRemoval = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textareaAfterEntityRemoval.dispatchEvent(undoEntityRemoval);
await flushTimers();
});
eq(undoEntityRemoval.defaultPrevented, true, "Ctrl+Z restores a token removed by the rich composer");
ok(
document.querySelector(".invocation-display--composer") !== null,
"undoing the programmatic Backspace restores the selected skill",
);
const restoredRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!restoredRichInput) throw new Error("rich composer did not return after undoing token removal");
const redoEntityRemoval = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
restoredRichInput.dispatchEvent(redoEntityRemoval);
await flushTimers();
});
eq(redoEntityRemoval.defaultPrevented, true, "Ctrl+Shift+Z redoes rich token removal");
ok(
document.querySelector(".invocation-display--composer") === null,
"redoing the programmatic Backspace removes the selected skill again",
);
await replaceComposerDraft(rerender, 2002, "/writing-plans");
await waitFor("plain composer after replacing the restored skill", () => Boolean(document.querySelector("textarea")));
await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not return after removing the skill");
@@ -1802,5 +2206,153 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "review", description: "Review the current task", kind: "skill" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const sessionA = "session:project:/repo:topic-a:session-a";
const sessionB = "session:project:/repo:topic-b:session-b";
const { root, rerender } = await renderComposer({ sessionKey: sessionA });
await replaceComposerDraft(rerender, 5000, "x/review");
await waitFor("session A slash menu", () => Boolean(document.querySelector(".slashmenu")));
await rerender({ sessionKey: sessionB, insertRequest: null });
await replaceComposerDraft(rerender, 5001, "b");
const sessionBInput = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!sessionBInput) throw new Error("session B textarea did not render");
await act(async () => {
sessionBInput.focus();
sessionBInput.setSelectionRange(1, 1);
sessionBInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "b", bubbles: true }));
await flushTimers();
});
await rerender({ sessionKey: sessionA, insertRequest: null });
eq(
(document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
"x/review",
"switching back restores session A slash draft",
);
await waitFor(
"restored session A slash menu",
() => Boolean(document.querySelector(".slashmenu")),
);
ok(
document.querySelector(".slashmenu") !== null,
"restoring a draft recomputes slash completion from its end caret",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "review", description: "Review the current task", kind: "skill" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const sessionA = "session:project:/repo:rich-topic-a:rich-session-a";
const sessionB = "session:project:/repo:rich-topic-b:rich-session-b";
const realRequestAnimationFrame = globalThis.requestAnimationFrame;
const queuedComposerFrames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (callback) => {
queuedComposerFrames.push(callback);
return queuedComposerFrames.length;
};
const { root, rerender } = await renderComposer({ sessionKey: sessionA });
await replaceComposerDraft(rerender, 6000, "/review");
await waitFor("session A first skill menu", () => Boolean(document.querySelector(".slashmenu")));
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("session A textarea did not render");
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("session A rich input did not render");
await appendRichComposerInput(richInput, " /review");
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("session A rich input disappeared");
const queryAtEnd = document.createRange();
queryAtEnd.selectNodeContents(richInput);
queryAtEnd.collapse(false);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(queryAtEnd);
await act(async () => {
richInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
await flushTimers();
});
await waitFor("session A second skill menu", () => Boolean(document.querySelector(".slashmenu")));
await rerender({ sessionKey: sessionB, insertRequest: null });
await replaceComposerDraft(rerender, 6001, "b");
await rerender({ sessionKey: sessionA, insertRequest: null });
await act(async () => {
let frameTime = 0;
while (queuedComposerFrames.length > 0) {
queuedComposerFrames.shift()?.(frameTime += 16);
}
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("session A rich input was not restored");
eq(richComposerTaskText(richInput), " /review", "switching back restores the rich invocation draft");
await waitFor(
"restored session A rich slash menu",
() => Boolean(document.querySelector(".slashmenu")),
);
ok(
document.querySelector(".slashmenu") !== null,
"restoring a rich invocation draft recomputes slash completion from its end caret",
);
await act(async () => {
richInput.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
eq(
richInput?.querySelectorAll(".composer-invocation-token").length,
2,
"the restored rich slash query can select a second skill",
);
eq(richInput ? richComposerTaskText(richInput) : "", " ", "selecting the restored query replaces its slash token");
await rerender({ sessionKey: sessionB, insertRequest: null });
eq(
(document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
"b",
"switching away again preserves the other session draft",
);
await act(async () => {
root.unmount();
});
globalThis.requestAnimationFrame = realRequestAnimationFrame;
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -56,6 +56,9 @@ eq(finalDeclaration(".composer-card--resized .composer__input", "height"), "100%
eq(finalDeclaration(".composer-card--resized .composer__input", "overflow-y"), "auto", "resized textarea scrolls internally");
eq(finalDeclaration(".composer-card--resized .composer__rich-input", "height"), "100%", "resized rich input fills the card's input area");
eq(finalDeclaration(".composer-card--resized .composer__rich-input", "overflow-y"), "auto", "resized rich input scrolls internally");
eq(finalDeclaration(".composer-invocation-caret-anchor", "min-width"), "1px", "invocation caret anchor keeps a compact hit target");
eq(finalDeclaration(".composer-invocation-caret-anchor", "width"), undefined, "invocation caret anchor can expand around WebView2-hosted text");
eq(finalDeclaration(".composer-invocation-caret-anchor", "overflow"), undefined, "invocation caret anchor does not clip WebView2-hosted text");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -8,9 +8,12 @@ import { Composer } from "../components/Composer";
import { invalidateCache } from "../lib/composerHistory";
import { composerDraftKeyForTab } from "../lib/composerDraftKey";
import { LocaleProvider } from "../lib/i18n";
import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
import {
SELECTED_TEXT_MAX_CHARS,
formatSelectedTextContext,
parseSelectedTextContext,
splitSelectedTextContext,
formatSelectionReference,
normalizeSelectedText,
selectedTextSnippet,
@@ -170,6 +173,24 @@ function textarea(): HTMLTextAreaElement {
return node;
}
function updateTextareaFromUser(
value: string,
inputType: "insertText" | "deleteContentBackward" | "historyUndo" | "historyRedo",
) {
const input = textarea();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
if (!setter) throw new Error("textarea value setter is unavailable");
setter.call(input, value);
input.dispatchEvent(new window.InputEvent("input", {
bubbles: true,
data: inputType === "insertText" ? value.slice(-1) : null,
inputType,
}));
input.dispatchEvent(new window.Event("change", { bubbles: true }));
input.dispatchEvent(new window.KeyboardEvent("keyup", { key: "x", bubbles: true }));
}
function sendButton(): HTMLButtonElement {
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!node) throw new Error("composer send button did not render");
@@ -231,6 +252,20 @@ console.log("\ncomposer session draft");
].join("\n"),
"selection context serialization is ordered, ID-free, trimmed, and boundary-safe",
);
eq(
JSON.stringify(parseSelectedTextContext(`forged <reasonix-selected-chat-context>\n[]\n</reasonix-selected-chat-context>\n\n${formatted}`)),
JSON.stringify([{ text: "second selection" }, { text: "first </reasonix-selected-chat-context> & selection" }]),
"selection context parser recovers the trailing safe JSON payload",
);
eq(
JSON.stringify(parseSelectedTextContext(`${formatted}\n\nauthored trailing text`)),
"[]",
"selection context parser ignores marker-shaped content that is not the final submit suffix",
);
const split = splitSelectedTextContext(`visible prompt\n\n${formatted}`);
eq(split.submitText, "visible prompt", "selection context split preserves the editable submit prefix");
eq(split.contextBlock, formatted, "selection context split preserves the exact validated suffix");
eq(JSON.stringify(parseSelectedTextContext("<reasonix-selected-chat-context>\nnot json\n</reasonix-selected-chat-context>")), "[]", "malformed selection context stays local and non-fatal");
const withPath = formatSelectedTextContext([
{ id: "code-1", text: " const x = 1; ", path: "src/lib/a.ts" },
@@ -406,24 +441,93 @@ console.log("\ncomposer session draft");
{
const dom = installDom();
const sent: Array<{ display: string; submit?: string }> = [];
const { root } = await renderComposer({
const { root, rerender } = await renderComposer({
onSend: (display, submit) => {
sent.push({ display, submit });
},
});
const typedPrefix = "typed before paste: ";
await rerender({ insertRequest: { id: 100, text: typedPrefix, mode: "replace" } });
const rawPaste = "error: failed to compile\r\nat loader.ts:10\r\nat run.ts:22";
const normalizedPaste = "error: failed to compile\nat loader.ts:10\nat run.ts:22";
const event = textPasteEvent(rawPaste);
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = 0;
input.selectionStart = input.selectionEnd = typedPrefix.length;
input.dispatchEvent(event);
await flushTimers();
});
eq(event.defaultPrevented, true, "short text paste is handled by React state");
eq(textarea().value, normalizedPaste, "short multiline paste is visible in the composer");
eq(textarea().value, typedPrefix + normalizedPaste, "short multiline paste is visible after existing text");
const undo = new window.KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true });
await act(async () => {
textarea().dispatchEvent(undo);
await flushTimers();
});
eq(undo.defaultPrevented, true, "Ctrl+Z handles the programmatic paste transaction");
eq(textarea().value, typedPrefix, "Ctrl+Z preserves the text typed before a short paste");
const redo = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redo);
await flushTimers();
});
eq(redo.defaultPrevented, true, "Ctrl+Shift+Z redoes the programmatic paste transaction");
eq(textarea().value, typedPrefix + normalizedPaste, "redo restores the short paste after existing text");
const undoAgain = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoAgain);
await flushTimers();
});
eq(textarea().value, typedPrefix, "a second undo returns to the pre-paste boundary");
await act(async () => {
updateTextareaFromUser(`${typedPrefix}X`, "insertText");
await flushTimers();
});
await rerender();
eq(textarea().value, `${typedPrefix}X`, "ordinary typing after paste undo updates the draft");
await act(async () => {
updateTextareaFromUser(typedPrefix, "deleteContentBackward");
await flushTimers();
});
await rerender();
const staleRedo = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(staleRedo);
await flushTimers();
});
eq(staleRedo.defaultPrevented, false, "a new native edit invalidates the programmatic redo transaction");
eq(textarea().value, typedPrefix, "stale redo cannot resurrect an earlier paste");
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = typedPrefix.length;
input.dispatchEvent(textPasteEvent(rawPaste));
await flushTimers();
});
await act(async () => {
sendButton().click();
@@ -431,8 +535,8 @@ console.log("\ncomposer session draft");
});
eq(sent.length, 1, "short multiline paste submits once");
eq(sent[0]?.display, normalizedPaste, "short multiline paste is preserved in display text");
eq(sent[0]?.submit, normalizedPaste, "short multiline paste is preserved in submit text");
eq(sent[0]?.display, typedPrefix + normalizedPaste, "short multiline paste is preserved in display text");
eq(sent[0]?.submit, typedPrefix + normalizedPaste, "short multiline paste is preserved in submit text");
await act(async () => {
root.unmount();
@@ -440,6 +544,460 @@ console.log("\ncomposer session draft");
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer();
const base = "base:";
const pasted = "paste";
await rerender({ insertRequest: { id: 101, text: base, mode: "replace" } });
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = base.length;
input.dispatchEvent(textPasteEvent(pasted));
await flushTimers();
});
eq(textarea().value, base + pasted, "interleaved-history test starts at the paste boundary");
await act(async () => {
updateTextareaFromUser(`${base}${pasted}X`, "insertText");
await flushTimers();
});
await rerender();
await act(async () => {
updateTextareaFromUser(base + pasted, "deleteContentBackward");
await flushTimers();
});
await rerender();
const undoNativeDelete = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoNativeDelete);
await flushTimers();
});
eq(
undoNativeDelete.defaultPrevented,
false,
"Ctrl+Z leaves a native deletion above the paste transaction to the browser",
);
eq(
textarea().value,
base + pasted,
"matching net text does not make the older paste transaction look newest",
);
await act(async () => {
updateTextareaFromUser(`${base}${pasted}X`, "historyUndo");
await flushTimers();
});
await rerender();
const undoNativeInsert = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoNativeInsert);
await flushTimers();
});
eq(
undoNativeInsert.defaultPrevented,
false,
"Ctrl+Z leaves the remaining native insertion to the browser",
);
await act(async () => {
updateTextareaFromUser(base + pasted, "historyUndo");
await flushTimers();
});
await rerender();
const undoPasteAfterNative = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoPasteAfterNative);
await flushTimers();
});
eq(
undoPasteAfterNative.defaultPrevented,
true,
"the paste transaction becomes undoable only after newer native edits are exhausted",
);
eq(textarea().value, base, "ordered undo eventually restores the pre-paste text");
const redoPasteBeforeNative = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoPasteBeforeNative);
await flushTimers();
});
eq(
redoPasteBeforeNative.defaultPrevented,
true,
"ordered redo restores the custom paste before its newer native edits",
);
eq(textarea().value, base + pasted, "custom redo returns to the browser redo boundary");
const redoNativeAfterPaste = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoNativeAfterPaste);
await flushTimers();
});
eq(
redoNativeAfterPaste.defaultPrevented,
false,
"newer native redo remains browser-owned after the paste redo",
);
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer();
await act(async () => {
textarea().dispatchEvent(textPasteEvent("first"));
await flushTimers();
});
await act(async () => {
updateTextareaFromUser("firstX", "insertText");
await flushTimers();
});
await rerender();
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(textPasteEvent("second"));
await flushTimers();
});
eq(textarea().value, "firstXsecond", "two custom pastes can surround a native edit");
const undoSecondPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoSecondPaste);
await flushTimers();
});
eq(undoSecondPaste.defaultPrevented, true, "the newest custom paste undoes first");
eq(textarea().value, "firstX", "undoing the second paste reaches its native boundary");
const undoInterleavedNative = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoInterleavedNative);
await flushTimers();
});
eq(
undoInterleavedNative.defaultPrevented,
false,
"the native edit between two custom pastes remains browser-owned",
);
await act(async () => {
updateTextareaFromUser("first", "historyUndo");
await flushTimers();
});
await rerender();
const undoFirstPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoFirstPaste);
await flushTimers();
});
eq(undoFirstPaste.defaultPrevented, true, "the older custom paste follows the native undo segment");
eq(textarea().value, "", "ordered undo reaches the start of the mixed timeline");
const redoFirstPaste = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoFirstPaste);
await flushTimers();
});
eq(redoFirstPaste.defaultPrevented, true, "ordered redo restores the first custom paste");
eq(textarea().value, "first", "redo reaches the native segment after the first paste");
const redoInterleavedNative = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoInterleavedNative);
await flushTimers();
});
eq(
redoInterleavedNative.defaultPrevented,
false,
"ordered redo returns the interleaved native edit to the browser",
);
await act(async () => {
updateTextareaFromUser("firstX", "historyRedo");
await flushTimers();
});
await rerender();
const redoSecondPaste = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoSecondPaste);
await flushTimers();
});
eq(redoSecondPaste.defaultPrevented, true, "the second custom paste redoes after the native segment");
eq(textarea().value, "firstXsecond", "mixed custom/native redo restores the final state");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
await act(async () => {
resetCustomShortcuts();
await flushTimers();
});
saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true });
let yoloToggles = 0;
const { root } = await renderComposer({
onToggleYoloApprovalMode: () => {
yoloToggles += 1;
},
});
await act(async () => {
textarea().dispatchEvent(textPasteEvent("pasted"));
await flushTimers();
});
const undoPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoPaste);
await flushTimers();
});
eq(undoPaste.defaultPrevented, true, "legacy Ctrl+Z YOLO binding yields to composer undo while editing");
eq(textarea().value, "", "legacy Ctrl+Z YOLO binding still undoes the paste");
eq(yoloToggles, 0, "legacy Ctrl+Z YOLO binding does not toggle YOLO while editing");
await act(async () => {
saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true, shift: true });
await flushTimers();
});
const legacyRedo = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(legacyRedo);
await flushTimers();
});
eq(legacyRedo.defaultPrevented, true, "legacy Ctrl+Shift+Z YOLO binding yields to composer redo while editing");
eq(textarea().value, "pasted", "legacy Ctrl+Shift+Z YOLO binding still redoes the paste");
eq(yoloToggles, 0, "legacy Ctrl+Shift+Z YOLO binding does not toggle YOLO while editing");
await act(async () => {
saveCustomShortcut("toolApproval.yolo", { key: "u", ctrl: true });
textarea().dispatchEvent(new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(textarea().value, "", "Ctrl+Z prepares a custom redo transaction after YOLO is rebound");
const ctrlYRedo = new window.KeyboardEvent("keydown", {
key: "y",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(ctrlYRedo);
await flushTimers();
});
eq(ctrlYRedo.defaultPrevented, true, "Ctrl+Y redoes the paste when the YOLO shortcut is rebound");
eq(textarea().value, "pasted", "Ctrl+Y restores the programmatic paste");
eq(yoloToggles, 0, "Ctrl+Y does not toggle YOLO after that action is rebound");
await act(async () => {
resetCustomShortcuts();
await flushTimers();
});
const defaultCtrlY = new window.KeyboardEvent("keydown", {
key: "y",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(defaultCtrlY);
await flushTimers();
});
eq(defaultCtrlY.defaultPrevented, true, "default Ctrl+Y remains reserved for the YOLO shortcut");
eq(yoloToggles, 1, "default Ctrl+Y still toggles YOLO exactly once");
eq(textarea().value, "pasted", "the YOLO shortcut does not mutate composer history");
await act(async () => root.unmount());
resetCustomShortcuts();
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer({
tabId: "tab-a",
sessionKey: "session:project:/repo:topic-a:session-a",
});
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
await act(async () => {
textarea().dispatchEvent(textPasteEvent(longPaste));
await flushTimers();
});
ok(document.querySelector(".composer__pasted-label") !== null, "long paste creates a folded block");
const previewButton = document.querySelector<HTMLButtonElement>(".composer__pasted-actions button");
if (!previewButton) throw new Error("long paste preview button did not render");
await act(async () => {
previewButton.click();
await flushTimers();
});
ok(document.querySelector(".composer__pasted-preview") !== null, "long paste preview opens without changing text history");
await rerender({
tabId: "tab-b",
sessionKey: "session:project:/repo:topic-b:session-b",
insertRequest: { id: 101, text: "draft B", mode: "replace" },
});
const unrelatedUndo = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(unrelatedUndo);
await flushTimers();
});
eq(unrelatedUndo.defaultPrevented, false, "another draft does not inherit the paste undo transaction");
eq(textarea().value, "draft B", "Ctrl+Z in another draft leaves its text unchanged");
await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
ok(document.querySelector(".composer__pasted-preview") !== null, "source draft restores its open long-paste preview");
const foldedUndo = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(foldedUndo);
await flushTimers();
});
eq(foldedUndo.defaultPrevented, true, "source draft keeps its own folded-paste undo transaction");
eq(textarea().value, "", "Ctrl+Z removes the folded-paste label");
ok(document.querySelector(".composer__pasted-label") === null, "Ctrl+Z removes the folded block state");
ok(document.querySelector(".composer__pasted-preview") === null, "Ctrl+Z also clears preview state with the folded block");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const { root } = await renderComposer();
const longPaste = Array.from({ length: 20 }, (_, index) => `expanded ${index + 1}`).join("\n");
await act(async () => {
textarea().dispatchEvent(textPasteEvent(longPaste));
await flushTimers();
});
const expandButton = document.querySelectorAll<HTMLButtonElement>(".composer__pasted-actions button")[1];
if (!expandButton) throw new Error("long paste expand button did not render");
await act(async () => {
expandButton.click();
await flushTimers();
});
eq(textarea().value, longPaste, "expanding a folded paste restores its original text");
ok(document.querySelector(".composer__pasted-label") === null, "expanded paste no longer renders a folded label");
const undoExpand = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoExpand);
await flushTimers();
});
eq(undoExpand.defaultPrevented, true, "Ctrl+Z treats pasted-block expansion as a custom transaction");
ok(document.querySelector(".composer__pasted-label") !== null, "undoing expansion restores the folded block");
const undoLongPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoLongPaste);
await flushTimers();
});
eq(undoLongPaste.defaultPrevented, true, "a second Ctrl+Z undoes the original folded paste");
eq(textarea().value, "", "undoing the original folded paste restores the empty draft");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const saveStarted = deferred<void>();
@@ -561,7 +1119,7 @@ console.log("\ncomposer session draft");
textarea().setSelectionRange(2, 2);
const menuItems = await openComposerInputMenu();
await act(async () => {
menuItems[2]?.click();
menuItems[4]?.click();
await flushTimers();
});
await rerender({
@@ -608,7 +1166,7 @@ console.log("\ncomposer session draft");
textarea().setSelectionRange(1, 4);
const menuItems = await openComposerInputMenu();
await act(async () => {
menuItems[0]?.click();
menuItems[2]?.click();
await clipboardWriteStarted.promise;
});
await rerender({
@@ -774,8 +1332,11 @@ console.log("\ncomposer session draft");
sendButton().click();
await flushTimers();
});
eq(sent[0]?.display, "Explain the selected behavior", "the visible user message stays as the user's draft");
// Selection labels show a snippet of the selected text
ok(sent[0]?.display.includes("[Chat:") && sent[0]?.display.includes("[Code: util.ts →"), "display includes selection labels with text snippet");
ok(sent[0]?.submit.includes("<reasonix-selected-chat-context>") === true, "submit appends the selected text context block");
eq(sent[0]?.submit.includes("--- Begin [Chat:"), false, "submit does not duplicate selected text in display-only marker blocks");
eq(sent[0]?.submit.split("selected assistant response").length - 1, 1, "selected chat text appears once in provider-visible submit bytes");
ok(
sent[0]?.submit.includes('[{"text":"selected assistant response"},{"path":"src/lib/util.ts","text":"const value = 1;"}]') === true,
"submit serializes chat and code selections deterministically",
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
}
const source = readFileSync(new URL("../components/MemoryPanel.tsx", import.meta.url), "utf8");
ok(source.includes("function memoryFactKey"), "Context Center must use a stable fact identity helper");
ok(source.includes("app.MemoryRevisionsForTab"), "Context Center must load revision history for the selected workspace");
ok(source.includes("app.RestoreMemoryRevisionForTab"), "Context Center must restore a selected revision");
ok(source.includes("view.lastRecall"), "Context Center must explain the last automatic recall");
ok(source.includes("view.conflicts"), "Context Center must explain project-over-global memory resolution");
ok(source.includes("view.instructionDiagnostics"), "Context Center must surface instruction diagnostics");
ok(source.includes("d.precedence"), "Context Center must show resolved instruction precedence");
console.log("context-center-contract: ok");
@@ -146,6 +146,31 @@ console.log("\ncontext window ring");
dom.window.close();
}
{
const dom = installDom();
installContextPanelMock(async () => contextPanelInfo(0));
const { root } = await renderRing({ turnCost: 0.125, currency: "$" });
const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
if (!button) throw new Error("missing context ring button");
await act(async () => {
button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
await wait(220);
});
const turnCostRow = [...document.querySelectorAll(".context-ring-popover__row")]
.find((row) => row.querySelector(".context-ring-popover__label")?.textContent === "turn cost");
eq(
turnCostRow?.querySelector(".context-ring-popover__value")?.textContent,
"$0.1250",
"turn cost uses the session currency before panel info is available",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const calls: string[] = [];
@@ -73,13 +73,14 @@ function installDom(language = "en-US") {
console.log("\ndecision surface");
// Plan keeps one explicit start decision, but both visible choices are direct
// buttons. Exit/stop remain global controls rather than extra card branches.
// Plan exposes start, revise, and leave-without-executing as direct buttons so
// declining the current plan never traps the user in Plan mode.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
const answers: Array<[boolean, boolean, boolean]> = [];
const revisions: string[] = [];
let exits = 0;
const approval: WireApproval = {
id: "plan-1",
tool: "exit_plan_mode",
@@ -93,6 +94,7 @@ console.log("\ndecision surface");
approval={approval}
onAnswer={(a, s, p) => answers.push([a, s, p])}
onRevisePlan={(text) => revisions.push(text)}
onExitPlan={() => { exits += 1; }}
onStop={() => undefined}
/>
</LocaleProvider>,
@@ -101,11 +103,11 @@ console.log("\ndecision surface");
});
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
eq(actions.length, 2, "Plan has start and revise actions only");
eq(actions.length, 3, "Plan has start, revise, and exit-without-executing actions");
eq(document.querySelector(".prompt-shelf__actions")?.getAttribute("role"), "group", "Plan actions use button-group semantics");
ok(actions.every((action) => action.getAttribute("role") === "button"), "Plan actions are announced as buttons");
ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
ok(!document.body.textContent?.includes("Exit plan mode"), "Plan card hides the exit branch");
ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
await act(async () => {
@@ -115,6 +117,46 @@ console.log("\ndecision surface");
ok(document.querySelector(".plan-revision__input") != null, "Revise opens the inline editor in one click");
eq(answers.length, 0, "Opening revision does not start execution");
await act(async () => {
actions[2].click();
actions[2].click();
await flushTimers(220);
});
eq(exits, 1, "Exit without executing runs once and ignores a double click");
eq(answers.length, 0, "Exit without executing never approves plan execution");
eq(revisions.length, 0, "Exit without executing does not submit a plan revision");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
let exits = 0;
await act(async () => {
root.render(
<LocaleProvider>
<ApprovalModal
approval={{ id: "plan-exit-key", tool: "exit_plan_mode", subject: "Plan ready" }}
onAnswer={() => undefined}
onExitPlan={() => { exits += 1; }}
onStop={() => undefined}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
await flushTimers(220);
});
eq(exits, 1, "number key 3 exits Plan without execution");
await act(async () => {
root.unmount();
});
@@ -425,17 +467,24 @@ console.log("\ndecision surface");
dom.window.close();
}
// Revise is one-click; optional empty feedback still works.
// A reviewer-confirmed scope/strategy transition is presented as a plan-level
// choice rather than another low-level tool warning.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
const decisions: Array<{ action: string; feedback?: string }> = [];
const approval: WireApproval = {
id: "guard-2",
tool: "write_file",
subject: "expand to b.go",
tool: "todo_write",
subject: "Update the active execution plan",
kind: "recovery",
recovery: { next_action: "edit b.go", change_kind: "scope", failed_summary: "a.go failed" },
recovery: {
next_action: "Update the active execution plan",
change_kind: "scope",
change_rationale: "Publishing the migration changes the product scope.",
plan_before: "1. Keep the public API [in_progress]\n2. Update the implementation [pending]",
plan_after: "1. Replace the public API [in_progress]\n2. Update the implementation [pending]\n3. Update the migration guide [pending]",
},
};
await act(async () => {
@@ -452,7 +501,19 @@ console.log("\ndecision surface");
await flushTimers();
});
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
let actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
ok(document.body.textContent?.includes("The execution plan needs your decision"), "material scope change uses a neutral plan-level title");
ok(document.body.textContent?.includes("Scope choice"), "plan card names the user-owned decision class");
ok(document.body.textContent?.includes("Removed from the previous plan"), "plan card identifies removed steps");
ok(document.body.textContent?.includes("Keep the public API"), "plan card shows the removed step");
ok(document.body.textContent?.includes("Added to the new plan"), "plan card identifies added steps");
ok(document.body.textContent?.includes("Replace the public API"), "plan card shows the replacement step");
ok(document.body.textContent?.includes("Update the migration guide"), "plan card shows newly added scope");
ok(!document.body.textContent?.includes("[in_progress]"), "plan delta omits progress-only status noise");
ok(actions[0].textContent?.includes("Adopt the new plan and continue"), "plan adoption remains explicit");
ok(actions[1].textContent?.includes("Do not adopt; tell Auto how to adjust"), "plan rejection opens a guided revision path");
ok(!actions[0].classList.contains("prompt-action--selected"), "plan adoption is not visually preselected");
ok(!actions[1].classList.contains("prompt-action--selected"), "plan adjustment is not visually preselected");
ok(!document.querySelector(".recovery-task-grant"), "unbounded scope change does not offer a task grant");
const detailsButton = document.querySelector(".prompt-shelf__header-button") as HTMLButtonElement;
ok(detailsButton.textContent?.includes("Technical details"), "technical diagnostics are available on demand");
@@ -463,12 +524,51 @@ console.log("\ndecision surface");
ok(document.querySelector(".recovery-details"), "technical details expand on request");
ok(document.querySelector(".recovery-details .recovery-detail-row"), "expanded diagnostics use restrained detail rows");
ok(!document.querySelector(".recovery-details .approval-reason"), "expanded diagnostics do not render an alert wall");
await act(async () => {
actions[1].click();
await flushTimers();
});
eq(decisions.length, 0, "opening plan adjustment does not reject or adopt the proposal");
const guidance = document.querySelector(".recovery-guidance__input") as HTMLTextAreaElement;
ok(guidance === document.activeElement, "plan adjustment opens and focuses the guidance field");
const feedback = "Keep the public API and update only the migration guide.";
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(guidance, feedback);
guidance.dispatchEvent(new dom.window.InputEvent("input", { bubbles: true, inputType: "insertText", data: feedback }));
guidance.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
guidance.dispatchEvent(new dom.window.KeyboardEvent("keyup", { key: ".", bubbles: true }));
await flushTimers();
});
const submitGuidance = document.querySelector(".recovery-guidance__actions .btn--primary") as HTMLButtonElement;
ok(submitGuidance.textContent?.includes("Submit adjustment guidance"), "plan guidance uses an explicit submit action");
ok(!submitGuidance.disabled, "non-empty plan guidance enables submission");
await act(async () => {
submitGuidance.click();
await flushTimers(220);
});
eq(decisions[0]?.action, "revise", "submitted plan guidance rejects the proposed transition");
eq(decisions[0]?.feedback, feedback, "plan adjustment forwards the user's exact guidance");
await act(async () => {
root.render(
<LocaleProvider>
<ApprovalModal
approval={{ ...approval, id: "guard-3" }}
onAnswer={() => undefined}
onResolveRecovery={(action, nextFeedback) => decisions.push({ action, feedback: nextFeedback })}
onStop={() => undefined}
/>
</LocaleProvider>,
);
await flushTimers();
});
actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
await act(async () => {
actions[0].click();
await flushTimers(220);
});
eq(decisions[0]?.action, "revise", "try another approach rejects immediately");
ok(!decisions[0]?.feedback, "empty optional feedback is allowed");
eq(decisions[1]?.action, "continue", "adopting the plan approves the waiting transition once");
await act(async () => {
root.unmount();
@@ -75,7 +75,7 @@ function baseReport(runtime: boolean): CapabilityDiagnosticsReport {
plugins: 0,
mcp_servers: 1,
},
instructions: { docs: [{ path: "<workspace>/AGENTS.md", scope: "project", order: 1 }] },
instructions: { docs: [{ path: "<workspace>/AGENTS.md", scope: "project", directory: "<workspace>", depth: 0, order: 1 }] },
skills: {
roots: [{ path: "<workspace>/.reasonix/skills", scope: "project", status: "ok" }],
entries: [{ name: "demo", path: "<workspace>/.reasonix/skills/demo/SKILL.md", status: "winner" }],
@@ -1,7 +1,8 @@
// Run: tsx src/__tests__/edit-replay.test.ts
import { replaySubmitText } from "../lib/editReplay";
import { replaySubmitText, replaySubmitTextPreservingSelectedContext } from "../lib/editReplay";
import { invocationSegmentsFromMessage, replaceInvocationTextRange, serializeInvocationSubmit, type ComposerInvocation } from "../lib/invocationDisplay";
import { formatSelectedTextContext, formatSelectionLabel, stripSelectionLabels } from "../lib/selectedTextContext";
let passed = 0;
let failed = 0;
@@ -36,6 +37,23 @@ eq(
"messages without hidden submit context use the rebuilt submit text",
);
const selectedReferences = [{ id: "chat-1", text: "selected assistant response" }];
const selectedLabel = formatSelectionLabel(selectedReferences[0]);
const selectedContext = formatSelectedTextContext(selectedReferences);
const selectedDisplay = `visible prompt ${selectedLabel}`;
const selectedEditableDisplay = stripSelectionLabels(selectedDisplay, selectedReferences);
const replayedSelectedEdit = replaySubmitTextPreservingSelectedContext(
`visible prompt\n\n${selectedContext}`,
selectedEditableDisplay,
"updated prompt",
"updated prompt",
);
eq(
replayedSelectedEdit,
`updated prompt\n\n${selectedContext}`,
"editing a selected-context message preserves the quoted context suffix without submitting its display label",
);
eq(
replaySubmitText("/reasonix-develop review this change", "review this change", "review the updated change", "review the updated change"),
"/reasonix-develop review the updated change",
@@ -0,0 +1,109 @@
// Run: tsx src/__tests__/goal-action-errors.test.tsx
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { useGoalActionHandler } from "../lib/goalAction";
import { ToastProvider } from "../lib/toast";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
console.log("\ngoal action bridge errors");
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.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
const unhandled: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => unhandled.push(reason);
const onWindowUnhandledRejection = (event: PromiseRejectionEvent) => unhandled.push(event.reason);
process.on("unhandledRejection", onUnhandledRejection);
window.addEventListener("unhandledrejection", onWindowUnhandledRejection);
function Probe() {
const { runGoalAction } = useGoalActionHandler();
const run = (label: string) => {
runGoalAction(async () => {
throw new Error(`${label} bridge failed`);
});
};
return (
<>
<button type="button" data-action="stop" onClick={() => run("stop goal")}>stop</button>
<button type="button" data-action="mode" onClick={() => run("switch mode")}>mode</button>
<button type="button" data-action="resync" onClick={() => run("background goal resync")}>resync</button>
</>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(<ToastProvider><Probe /></ToastProvider>);
});
for (const action of ["stop", "mode", "resync"]) {
await act(async () => {
document.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)?.click();
await flushPromises();
});
}
const errors = Array.from(document.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent);
ok(errors.includes("stop goal bridge failed"), "rejected Stop Goal action shows an error toast");
ok(errors.includes("switch mode bridge failed"), "rejected mode action shows an error toast");
ok(errors.includes("background goal resync bridge failed"), "rejected background Goal resync shows an error toast");
ok(unhandled.length === 0, "handled Goal action rejections do not emit unhandledrejection");
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
ok(
/runGoalAction\(\(\) => applyCollaborationMode\(collaborationMode === "plan" \? "normal" : "plan"\)\)/.test(appSource),
"mode shortcut routes through the rejection handler",
);
ok(
/runGoalAction\(async \(\) => \{[\s\S]{0,260}setControllerComposerProfileForTab\([\s\S]{0,260}propagateError: true/.test(appSource),
"background Goal resync routes through the rejection handler",
);
ok(appSource.includes("onClearGoal={clearGoalFromUi}"), "Composer Stop Goal routes through the rejection handler");
ok(appSource.includes("onSetCollaborationMode={setCollaborationModeFromUi}"), "Composer mode changes route through the rejection handler");
ok(/if \(model\) \{\s*await switchModel\(model\[1\]\);/.test(appSource), "/model awaits Goal restoration failures");
ok(
/await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource),
"failed Goal bridge calls cannot patch local Goal state or user intent",
);
await act(async () => {
root.unmount();
});
process.off("unhandledRejection", onUnhandledRejection);
window.removeEventListener("unhandledrejection", onWindowUnhandledRejection);
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,301 @@
// Run: tsx src/__tests__/goal-activation-tab-routing.test.tsx
//
// App/bridge harness for the first Goal + structured Skill path: hang the
// combined backend call for A, switch active to B, then assert the source tab
// and workbench target token stayed fixed.
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { activateGoalAndSubmitOnTab } from "../lib/goalSubmit";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
return {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: "topic-a",
topicTitle: "A",
label: "model",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo",
...overrides,
};
}
function metaFor(tab: TabMeta): Meta {
return {
label: tab.label,
ready: tab.ready,
startupErr: tab.startupErr,
eventChannel: "agent:event",
cwd: tab.cwd || tab.workspaceRoot,
workspaceRoot: tab.workspaceRoot,
workspaceName: tab.workspaceName,
workspacePath: tab.workspacePath,
gitBranch: tab.gitBranch,
autoApproveTools: false,
bypass: false,
collaborationMode: tab.collaborationMode ?? "normal",
toolApprovalMode: tab.toolApprovalMode ?? "ask",
tokenMode: tab.tokenMode ?? "full",
goal: tab.goal ?? "",
goalStatus: tab.goal ? "running" : "stopped",
};
}
console.log("\ngoal activation tab routing");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const tabA = tabMeta({ id: "tab-a", topicId: "topic-a", topicTitle: "A", active: true });
const tabB = tabMeta({ id: "tab-b", topicId: "topic-b", topicTitle: "B", active: false, cwd: "/repo-b", workspaceRoot: "/repo-b", workspacePath: "/repo-b" });
let tabs: TabMeta[] = [tabA, tabB];
const initialGoalCalls: string[] = [];
let releaseGoal!: () => void;
const goalGate = new Promise<void>((resolve) => {
releaseGoal = resolve;
});
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => tabs.map((tab) => ({ ...tab })),
SetActiveTab: async (tabId: string) => {
tabs = tabs.map((tab) => ({ ...tab, active: tab.id === tabId }));
},
MetaForTab: async (tabId: string) => {
const tab = tabs.find((entry) => entry.id === tabId) ?? tabA;
return metaFor(tab);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SetGoalForTab: async (tabID: string, goal: string) => {
tabs = tabs.map((tab) =>
tab.id === tabID
? { ...tab, goal, collaborationMode: goal ? "goal" : "normal" }
: tab,
);
},
SubmitInitialGoalToTab: async (
tabID: string,
goal: string,
display: string,
_input: string,
invocations: { name: string }[],
collaborationMode: string,
toolApprovalMode: string,
targetKind: string,
targetIdentityGen: number,
targetRequestSeq: number,
): Promise<string[]> => {
await goalGate;
initialGoalCalls.push(
`${tabID}:${goal}:${display}:${invocations[0]?.name ?? ""}:${collaborationMode}:${toolApprovalMode}:${targetKind}:${targetIdentityGen}:${targetRequestSeq}`,
);
tabs = tabs.map((tab) =>
tab.id === tabID
? { ...tab, goal, collaborationMode: goal ? "goal" : "normal" }
: tab,
);
return [];
},
SubmitInvocationsToTab: async () => {
throw new Error("split SubmitInvocationsToTab must not be used for initial Goals");
},
SubmitToTab: async () => {
throw new Error("plain SubmitToTab must not be used for structured first Goal turns");
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitForActive("tab-a");
eq(controller?.activeTabId, "tab-a", "harness starts on tab A");
const sourceTabId = "tab-a";
const pending = activateGoalAndSubmitOnTab({
tabId: sourceTabId,
target: { kind: "ssh", identityGen: 7, requestSeq: 11 },
displayText: "Cross-tab safe goal",
submitText: "/ui-ux-pro-max Cross-tab safe goal",
structured: {
display: "/ui-ux-pro-max Cross-tab safe goal",
input: "Cross-tab safe goal",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
sendToTab: (tabId, goal, display, submit, structured, target) => {
if (!controller) throw new Error("controller missing");
if (!target) throw new Error("target missing");
return controller.sendToTab(tabId, display, submit, undefined, structured, {
goal,
target,
collaborationMode: "normal",
toolApprovalMode: "ask",
});
},
});
// While the atomic bridge call for A is suspended, the UI switches to tab B.
// The in-flight call must keep both its tab and target token.
await act(async () => {
await controller?.switchTab("tab-b", tabB);
await flushPromises();
});
eq(controller?.activeTabId, "tab-b", "active tab switched to B during deferred Goal activation");
releaseGoal();
await act(async () => {
await pending;
await flushPromises();
});
eq(
initialGoalCalls.join("|"),
"tab-a:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:normal:ask:ssh:7:11",
"atomic Goal submit kept source tab A and its Remote target token",
);
eq(initialGoalCalls.length, 1, "atomic Goal submit ran once");
// Bridge failure path: controller must reject without falling back to the split
// structured submit.
const failedInitialGoalCalls: string[] = [];
const failInvokeCalls: string[] = [];
(window.go.main.App as AppBindings).SubmitInitialGoalToTab = async (tabID: string) => {
failedInitialGoalCalls.push(tabID);
throw new Error("workbench target changed");
};
(window.go.main.App as AppBindings).SubmitInvocationsToTab = async (tabID: string) => {
failInvokeCalls.push(tabID);
};
let activationFailed = false;
await act(async () => {
try {
await activateGoalAndSubmitOnTab({
tabId: "tab-a",
target: { kind: "ssh", identityGen: 7, requestSeq: 11 },
displayText: "Must not run skill",
submitText: "/ui-ux-pro-max Must not run skill",
structured: {
display: "/ui-ux-pro-max Must not run skill",
input: "Must not run skill",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
sendToTab: (tabId, goal, display, submit, structured, target) =>
controller!.sendToTab(tabId, display, submit, undefined, structured, {
goal,
target: target!,
collaborationMode: "normal",
toolApprovalMode: "ask",
}),
});
} catch (error) {
activationFailed = error instanceof Error && error.message.includes("workbench target changed");
}
await flushPromises();
});
eq(activationFailed, true, "controller propagates atomic Goal bridge rejection");
eq(failedInitialGoalCalls.join("|"), "tab-a", "failed atomic submit still targeted source tab A");
eq(failInvokeCalls.length, 0, "failed atomic Goal submit does not call split SubmitInvocationsToTab");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
async function waitForActive(tabId: string) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (controller?.activeTabId === tabId) return;
await act(async () => {
await flushPromises();
});
}
throw new Error(`timed out waiting for active tab ${tabId}`);
}
@@ -157,6 +157,34 @@ function printResult(r: BenchResult): void {
}
console.log("\nhistory performance benchmark");
for (const c of cases) {
printResult(runCase(c));
const results = cases.map(runCase);
for (const result of results) {
printResult(result);
}
const failures: string[] = [];
for (let index = 0; index < results.length; index += 1) {
const result = results[index];
const input = cases[index];
const expectedMessages = input.turns * (2 + input.toolsPerTurn);
if (result.messages !== expectedMessages) failures.push(`${result.name}: unexpected message count`);
if (result.turnGroups !== input.turns) failures.push(`${result.name}: unexpected turn-group count`);
if (result.convertMs > 1_000 || result.reducerMs > 1_000 || result.transcriptComputeMs > 1_000) {
failures.push(`${result.name}: exceeded 1s responsiveness ceiling`);
}
}
const full10KB = results.find((result) => result.name === "200-turns-full-10KB");
const archived10KB = results.find((result) => result.name === "200-turns-archived-10KB");
if (!full10KB || full10KB.itemStringBytes * 10 >= full10KB.jsonBytes) {
failures.push("restored full tool results retained too much source text");
}
if (!archived10KB || archived10KB.itemStringBytes * 5 >= archived10KB.jsonBytes) {
failures.push("restored archived tool results retained too much source text");
}
if (failures.length > 0) {
for (const failure of failures) process.stderr.write(`FAIL ${failure}\n`);
process.exit(1);
}
process.stdout.write("PASS long-history performance contracts\n");
@@ -7,9 +7,11 @@ import {
formatShortcutCombo,
formatShortcutComboParts,
isCloseTabShortcut,
isReservedComposerHistoryShortcut,
matchesShortcut,
shortcutAcceptsCombo,
shortcutConflict,
shortcutDefinition,
type ShortcutPlatform,
} from "../lib/keyboardShortcuts";
import { topicShortcutIndexFromEvent, topicShortcutLabel } from "../lib/topicShortcuts";
@@ -117,6 +119,18 @@ eq(shortcutAcceptsCombo("composer.send", { key: "s", ctrl: true }), false, "comp
eq(shortcutAcceptsCombo("app.newSession", { key: "s", ctrl: true }), true, "unrestricted shortcuts still accept other keys");
eq(formatShortcutCombo(defaultShortcutCombo("composer.newline", "darwin"), "darwin"), "⇧Enter", "formats the mac newline chord");
eq(formatShortcutCombo(defaultShortcutCombo("composer.newline", "windows"), "windows"), "Shift+Enter", "formats the Windows newline chord");
eq(matchesShortcut(event("z", { metaKey: true }), "composer.undo", "darwin"), true, "Cmd+Z matches composer undo on macOS");
eq(matchesShortcut(event("z", { ctrlKey: true }), "composer.undo", "windows"), true, "Ctrl+Z matches composer undo on Windows");
eq(matchesShortcut(event("z", { metaKey: true, shiftKey: true }), "composer.redo", "darwin"), true, "Cmd+Shift+Z matches composer redo on macOS");
eq(matchesShortcut(event("z", { ctrlKey: true, shiftKey: true }), "composer.redo", "linux"), true, "Ctrl+Shift+Z matches composer redo on Linux");
eq(matchesShortcut(event("y", { ctrlKey: true }), "composer.redo", "windows"), false, "Ctrl+Y remains a Composer-level conditional fallback");
eq(isReservedComposerHistoryShortcut(event("z", { metaKey: true }), "darwin"), true, "macOS undo is reserved while editing");
eq(isReservedComposerHistoryShortcut(event("z", { ctrlKey: true, shiftKey: true }), "windows"), true, "Windows redo is reserved while editing");
eq(isReservedComposerHistoryShortcut(event("y", { ctrlKey: true }), "windows"), false, "conditional Ctrl+Y redo does not reserve a global shortcut");
eq(shortcutDefinition("composer.undo").configurable, false, "composer undo is visible but locked to the native editing chord");
eq(shortcutDefinition("composer.redo").configurable, false, "composer redo is visible but locked to the native editing chord");
eq(shortcutConflict("app.newSession", { key: "z", ctrl: true }, "windows")?.action, "composer.undo", "custom shortcuts cannot take the Windows undo chord");
eq(shortcutConflict("app.newSession", { key: "z", ctrl: true, shift: true }, "windows")?.action, "composer.redo", "custom shortcuts cannot take the Windows redo chord");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,71 @@
// Run: tsx src/__tests__/language-highlighting.test.ts
import { resolveLang } from "../lib/highlight";
import {
FILE_LANGUAGE_BY_EXTENSION,
FILE_LANGUAGE_BY_NAME,
extToLang,
pathToLang,
} from "../lib/lang";
import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\nlanguage highlighting");
const pathCases = [
["src/App.cs", "csharp", "csharp"],
["src/Component.tsx", "tsx", "typescript"],
["src/view.xaml", "xaml", "xml"],
["src/index.html", "html", "xml"],
["config/app.toml", "toml", "ini"],
["Dockerfile", "dockerfile", "dockerfile"],
["containers/Dockerfile.dev", "dockerfile", "dockerfile"],
["C:\\repo\\Containerfile.windows", "dockerfile", "dockerfile"],
["Makefile", "makefile", "makefile"],
["build/GNUmakefile.release", "makefile", "makefile"],
["CMakeLists.txt", "cmake", "cmake"],
["public/.htaccess", "apache", "apache"],
["config/httpd.conf", "apache", "apache"],
["config/nginx.conf", "nginx", "nginx"],
["build/module.makefile", "makefile", "makefile"],
] as const;
for (const [path, semantic, lexer] of pathCases) {
eq(pathToLang(path), semantic, `${path} has one semantic path mapping`);
eq(extToLang(path), semantic, `${path} keeps tool diffs consistent`);
eq(languageFor(path), semantic, `${path} keeps file previews consistent`);
eq(resolveLang(semantic), lexer, `${path} resolves to a registered lexer`);
}
const mappedLanguages = new Set([
...Object.values(FILE_LANGUAGE_BY_EXTENSION),
...Object.values(FILE_LANGUAGE_BY_NAME),
]);
for (const language of mappedLanguages) {
eq(resolveLang(language) !== "", true, `${language} mapping has a registered lexer`);
}
for (const [path, fenceTag] of [
["Component.tsx", "tsx"],
["template.html", "html"],
["settings.toml", "toml"],
["View.xaml", "xaml"],
] as const) {
const fenceLine = formatSelectionReference(path, "sample").split("\n")[2];
eq(fenceLine, `\`\`\`${fenceTag}`, `${path} preserves its provider-visible semantic fence tag`);
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -6,13 +6,27 @@ import { CodeViewer } from "../components/CodeViewer";
import LineNumberCode, {
findCodeMatches,
highlightLineMatches,
MAX_HIGHLIGHT_BYTES,
MAX_HIGHLIGHT_LINES,
MAX_SEARCH_MATCHES,
shouldHighlightCode,
splitHighlightedCodeLines,
} from "../components/editors/LineNumberCode";
import { highlightToHtml } from "../lib/highlight";
import {
findRegexCodeMatches,
MAX_REGEX_PATTERN_LENGTH,
MAX_REGEX_SOURCE_LENGTH,
type RegexSearchRequest,
type RegexSearchResponse,
} from "../components/editors/codeSearch";
import {
startRegexSearch,
type RegexSearchWorker,
} from "../components/editors/regexSearchClient";
import {
highlightToHtml,
MAX_HIGHLIGHT_BYTES,
MAX_HIGHLIGHT_LINES,
shouldHighlightCode,
shouldHighlightSource,
} from "../lib/highlight";
import { LocaleProvider } from "../lib/i18n";
let passed = 0;
@@ -36,6 +50,16 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForSelector(container: ParentNode, selector: string, timeoutMs = 1_000): Promise<Element> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const element = container.querySelector(selector);
if (element) return element;
await act(async () => wait(10));
}
throw new Error(`Timed out waiting for ${selector}`);
}
console.log("\nline-number code viewer");
const repeatedMatches = findCodeMatches("x\nx\nx", "x").matches;
@@ -49,6 +73,191 @@ ok(
findCodeMatches("猫 猫咪 猫", "猫", false, true).matches.length === 2,
"whole-word matching respects Unicode word characters",
);
ok(
findCodeMatches("a+b aab a+b", "a+b").matches.length === 2,
"treats regex metacharacters as literal search text",
);
const regexRequest = (overrides: Partial<RegexSearchRequest> = {}): RegexSearchRequest => ({
requestId: 1,
source: "foo1 foo22\nbar3",
pattern: String.raw`foo\d+`,
caseSensitive: false,
wholeWord: false,
maxMatches: MAX_SEARCH_MATCHES,
...overrides,
});
const regexMatches = findRegexCodeMatches(regexRequest());
ok(regexMatches.ok && regexMatches.result.matches.length === 2, "regex mode evaluates explicit patterns");
ok(
regexMatches.ok && regexMatches.result.matches.map((match) => match.start).join(",") === "0,5",
"regex mode returns JavaScript-compatible UTF-16 offsets",
);
const lineAnchoredRegex = findRegexCodeMatches(regexRequest({
source: "foo\nfoo",
pattern: "^foo",
}));
ok(
lineAnchoredRegex.ok
&& lineAnchoredRegex.result.matches.length === 2
&& lineAnchoredRegex.result.matches.map((match) => match.lineIndex).join(",") === "0,1",
"regex anchors continue to address individual source lines",
);
const multilineRegex = findRegexCodeMatches(regexRequest({
source: "foo\nbar",
pattern: String.raw`foo\nbar`,
}));
ok(!multilineRegex.ok && multilineRegex.error === "multiline_unsupported", "reports unsupported multiline regex matches");
const regexLiteralDifference = findRegexCodeMatches(regexRequest({
source: "a+b aab aaab",
pattern: "a+b",
}));
ok(
regexLiteralDifference.ok && regexLiteralDifference.result.matches.length === 2,
"regex mode keeps metacharacter semantics separate from literal search",
);
const invalidRegex = findRegexCodeMatches(regexRequest({ pattern: "(" }));
ok(!invalidRegex.ok && invalidRegex.error === "invalid_pattern", "reports invalid regular expressions");
const zeroLengthRegex = findRegexCodeMatches(regexRequest({ pattern: "^" }));
ok(
!zeroLengthRegex.ok && zeroLengthRegex.error === "zero_length_unsupported",
"rejects zero-length regex matches that cannot be highlighted safely",
);
const mixedLengthRegex = findRegexCodeMatches(regexRequest({
source: "abc",
pattern: ".*",
}));
ok(
mixedLengthRegex.ok
&& mixedLengthRegex.result.matches.length === 1
&& mixedLengthRegex.result.matches[0].start === 0
&& mixedLengthRegex.result.matches[0].end === 3,
"keeps non-empty regex results when the expression also produces an empty match",
);
const longRegex = findRegexCodeMatches(regexRequest({ pattern: "x".repeat(MAX_REGEX_PATTERN_LENGTH + 1) }));
ok(!longRegex.ok && longRegex.error === "pattern_too_long", "caps regular-expression length");
const oversizedRegexSource = findRegexCodeMatches(regexRequest({
source: "x".repeat(MAX_REGEX_SOURCE_LENGTH + 1),
}));
ok(
!oversizedRegexSource.ok && oversizedRegexSource.error === "source_too_large",
"caps source copied into the regex worker",
);
const cappedRegex = findRegexCodeMatches(regexRequest({
source: "x x x",
pattern: "x",
maxMatches: 2,
}));
ok(
cappedRegex.ok && cappedRegex.result.matches.length === 2 && cappedRegex.result.truncated,
"caps regex result sets before returning them to the UI",
);
class FakeRegexWorker implements RegexSearchWorker {
onmessage: ((event: MessageEvent<RegexSearchResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
posted: RegexSearchRequest[] = [];
terminated = false;
postMessage(request: RegexSearchRequest) {
this.posted.push(request);
}
terminate() {
this.terminated = true;
}
respond(response: RegexSearchResponse) {
this.onmessage?.({ data: response } as MessageEvent<RegexSearchResponse>);
}
}
const timedOutWorker = new FakeRegexWorker();
let timeoutCallback: (() => void) | null = null;
let timeoutResponse: RegexSearchResponse | null = null;
startRegexSearch(
regexRequest({ requestId: 2 }),
{ onResponse: (response) => { timeoutResponse = response; } },
{
createWorker: async () => timedOutWorker,
setTimer: (callback) => {
timeoutCallback = callback;
return 1;
},
clearTimer: () => {},
},
);
await Promise.resolve();
timeoutCallback?.();
ok(timedOutWorker.terminated, "hard timeout terminates a stuck regex worker");
ok(
timeoutResponse != null && !timeoutResponse.ok && timeoutResponse.error === "timeout",
"hard timeout reports a bounded search failure",
);
let creationTimeoutCallback: (() => void) | null = null;
let creationTimeoutResponse: RegexSearchResponse | null = null;
startRegexSearch(
regexRequest({ requestId: 5 }),
{ onResponse: (response) => { creationTimeoutResponse = response; } },
{
createWorker: () => new Promise<RegexSearchWorker>(() => {}),
setTimer: (callback) => {
creationTimeoutCallback = callback;
return 5;
},
clearTimer: () => {},
},
);
creationTimeoutCallback?.();
ok(
creationTimeoutResponse != null
&& !creationTimeoutResponse.ok
&& creationTimeoutResponse.error === "timeout",
"hard timeout covers a worker that never finishes initializing",
);
const staleWorker = new FakeRegexWorker();
let staleResponseAccepted = false;
const cancelStaleSearch = startRegexSearch(
regexRequest({ requestId: 3 }),
{ onResponse: () => { staleResponseAccepted = true; } },
{
createWorker: async () => staleWorker,
setTimer: () => 2,
clearTimer: () => {},
},
);
await Promise.resolve();
cancelStaleSearch();
staleWorker.respond({
requestId: 3,
ok: true,
result: { matches: [], truncated: false },
});
ok(staleWorker.terminated, "cancelling a stale request terminates its worker");
ok(!staleResponseAccepted, "discarded requests cannot update search results");
const completedWorker = new FakeRegexWorker();
let completedResponse: RegexSearchResponse | null = null;
let completedTimerCleared = false;
startRegexSearch(
regexRequest({ requestId: 4 }),
{ onResponse: (response) => { completedResponse = response; } },
{
createWorker: async () => completedWorker,
setTimer: () => 3,
clearTimer: () => { completedTimerCleared = true; },
},
);
await Promise.resolve();
completedWorker.respond({
requestId: 4,
ok: true,
result: { matches: [], truncated: false },
});
ok(completedResponse?.requestId === 4, "accepts the current worker response");
ok(completedWorker.terminated && completedTimerCleared, "successful searches release worker and timer resources");
const cappedMatches = findCodeMatches("x".repeat(MAX_SEARCH_MATCHES + 1), "x");
ok(cappedMatches.matches.length === MAX_SEARCH_MATCHES, "caps pathological result sets");
@@ -56,6 +265,15 @@ ok(cappedMatches.truncated, "reports capped result sets to the viewer");
ok(shouldHighlightCode(MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES), "keeps syntax highlighting within its budget");
ok(!shouldHighlightCode(MAX_HIGHLIGHT_BYTES + 1, 1), "falls back to plain text above the byte budget");
ok(!shouldHighlightCode(1, MAX_HIGHLIGHT_LINES + 1), "falls back to plain text above the line budget");
ok(shouldHighlightSource("const value = 1;"), "keeps ordinary chat code within the shared budget");
ok(
!shouldHighlightSource("é".repeat(Math.floor(MAX_HIGHLIGHT_BYTES / 2) + 1)),
"measures the shared byte budget as UTF-8 rather than UTF-16 code units",
);
ok(
!shouldHighlightSource("const value = 1;\n".repeat(MAX_HIGHLIGHT_LINES)),
"applies the shared line budget without requiring a workspace preview",
);
const entitySource = "const x = \"<&\";";
for (const query of ["<", "&"]) {
@@ -137,6 +355,41 @@ ok(
container.querySelectorAll(".code-block__wrap")[1].querySelector(".code-search") == null,
"does not fan the shortcut out to sibling viewers",
);
const firstViewer = container.querySelectorAll<HTMLElement>(".code-block__wrap")[0];
ok(
firstViewer.querySelector(".code-block__copy") == null,
"removes the covered floating copy control while search is open",
);
ok(
firstViewer.querySelector('.code-search .code-search__copy[aria-label="Copy"]') != null,
"keeps copy available as a visible search-toolbar action",
);
const pendingRequestContainer = document.createElement("div");
document.body.appendChild(pendingRequestContainer);
const pendingRequestRoot = createRoot(pendingRequestContainer);
let pendingRequestConsumed = false;
await act(async () => {
pendingRequestRoot.render(
<LocaleProvider>
<LineNumberCode
value="pending search"
showLineNumbers
searchRequestPending
onSearchRequestConsumed={() => {
pendingRequestConsumed = true;
}}
/>
</LocaleProvider>,
);
await flush();
});
ok(
pendingRequestContainer.querySelector(".code-search") != null,
"consumes a search request that arrived before the editor mounted",
);
ok(pendingRequestConsumed, "acknowledges a consumed search request");
await act(async () => pendingRequestRoot.unmount());
const searchInput = container.querySelector<HTMLInputElement>(".code-search__input")!;
await act(async () => {
@@ -176,8 +429,6 @@ await act(async () => {
searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await flush();
});
await act(async () => {
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await flush();
});
@@ -193,6 +444,39 @@ await act(async () => {
});
ok(caseToggle.getAttribute("aria-pressed") === "true", "exposes the active match-case state");
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
setter?.call(searchInput, "");
searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await wait(130);
});
const regexToggle = container.querySelector<HTMLButtonElement>('[aria-label="Use regular expression"]')!;
ok(regexToggle.getAttribute("aria-pressed") === "false", "keeps regex mode disabled by default");
await act(async () => {
regexToggle.click();
await flush();
});
ok(regexToggle.getAttribute("aria-pressed") === "true", "exposes regex mode as an explicit advanced toggle");
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
setter?.call(searchInput, "x".repeat(MAX_REGEX_PATTERN_LENGTH + 1));
searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await wait(130);
});
ok(
container.querySelector(".code-search__count--error")?.textContent === "Expression too long",
"shows a localized error before unsafe regex work starts",
);
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Close search"]')?.click();
await flush();
});
ok(firstViewer.querySelector(".code-search") == null, "closes the viewer-scoped search toolbar");
ok(firstViewer.querySelector(".code-block__copy") != null, "restores the floating copy control after search closes");
await act(async () => root.unmount());
const largeContainer = document.createElement("div");
@@ -224,12 +508,40 @@ await act(async () => {
<CodeViewer value="const unchanged = true;" language="typescript" />
</LocaleProvider>,
);
await flush();
});
// React.lazy may resolve after more than one task under React 19. Wait for the
// real viewer instead of asserting against the transient Suspense fallback.
await waitForSelector(defaultContainer, "pre.code.hljs");
ok(defaultContainer.querySelector("pre.code.hljs") != null, "keeps the established viewer as the default seam");
ok(defaultContainer.querySelector(".code--lines") == null, "requires an explicit line-number opt-in");
ok(defaultContainer.querySelector(".code-block__copy") != null, "keeps copy available on default code blocks");
await act(async () => defaultRoot.unmount());
const largeChatContainer = document.createElement("div");
document.body.appendChild(largeChatContainer);
const largeChatRoot = createRoot(largeChatContainer);
const largeChatValue = "const value = 1;\n".repeat(MAX_HIGHLIGHT_LINES);
await act(async () => {
largeChatRoot.render(
<LocaleProvider>
<CodeViewer value={largeChatValue} language="typescript" />
</LocaleProvider>,
);
});
await waitForSelector(largeChatContainer, 'pre[data-highlight-mode="plain"]');
ok(
largeChatContainer.querySelector('pre[data-highlight-mode="plain"]') != null,
"ordinary chat blocks reuse the shared syntax budget",
);
ok(
largeChatContainer.querySelector(".hljs-keyword") == null,
"oversized chat blocks skip syntax token generation",
);
ok(
largeChatContainer.querySelector("code")?.textContent === largeChatValue,
"oversized chat blocks preserve their plain-text content",
);
await act(async () => largeChatRoot.unmount());
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -7,6 +7,9 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import { splitStableMarkdownSections, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
import MermaidDiagram from "../components/MermaidDiagram";
import {
__setMermaidPanZoomFactoryForTest,
@@ -23,6 +26,8 @@ import { REMOTE_MARKDOWN_IMAGE_PATH } from "../lib/markdownImage";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
const markdownSource = readFileSync(resolve(testDir, "../components/Markdown.tsx"), "utf8");
const messageSource = readFileSync(resolve(testDir, "../components/Message.tsx"), "utf8");
let passed = 0;
let failed = 0;
@@ -133,6 +138,19 @@ function parseSvg(svg: string): Document {
console.log("\nmermaid rendering");
{
ok(markdownSource.includes("requestAnimationFrame"), "streaming markdown commits on an animation frame");
ok(markdownSource.includes("streamingMarkdownCommitInterval"), "streaming markdown applies an adaptive parse budget");
ok(markdownSource.includes('className="md md--stream-tail"'), "streaming markdown exposes an immediate lightweight tail");
ok(markdownSource.includes("requestIdleCallback"), "large Markdown finalization waits for browser idle time");
ok(markdownSource.includes("reasonix:markdown-finalize"), "large Markdown finalization emits a performance measure");
ok(markdownSource.includes("splitStableMarkdownSections"), "large Markdown retains completed top-level sections");
ok(markdownRendererSource.includes("bare = false"), "stable Markdown sections share one semantic container");
ok(
styles.includes(".md > :where(") && styles.includes("contain-intrinsic-size: auto 72px"),
"Markdown blocks skip offscreen layout while preserving learned intrinsic sizes",
);
ok(markdownSource.includes("streaming?: boolean"), "Markdown exposes an explicit streaming state");
ok(messageSource.includes("streaming={item.streaming}"), "assistant messages pass streaming state to Markdown");
ok(
markdownRendererSource.includes('lazy(() => import("./MermaidDiagram"))'),
"MarkdownRenderer lazy-loads the Mermaid renderer",
@@ -143,6 +161,152 @@ console.log("\nmermaid rendering");
);
}
{
const section = (index: number) => `# Section ${index}\n\n${`paragraph-${index} `.repeat(700)}\n\n`;
const document = Array.from({ length: 8 }, (_, index) => section(index)).join("");
const chunks = splitStableMarkdownSections(document);
ok(chunks.length >= 4, "large headed Markdown is divided into bounded stable chunks");
eq(chunks.join(""), document, "stable Markdown chunking preserves every source byte");
const appended = splitStableMarkdownSections(document + section(8));
eq(appended.slice(0, chunks.length).join(""), document, "appending a section leaves all completed chunks unchanged");
const fenced = `${section(0)}\`\`\`text\n# not a heading\n${"fenced content\n".repeat(900)}\`\`\`\n\n${section(1)}`;
const fencedChunks = splitStableMarkdownSections(fenced);
eq(fencedChunks.join(""), fenced, "fenced Markdown chunking preserves source bytes");
ok(!fencedChunks.some((chunk) => chunk.startsWith("# not a heading")), "headings inside fenced code never become section boundaries");
const referenced = `${document}\n[shared]: https://example.com\n\nUse [shared].\n`;
eq(splitStableMarkdownSections(referenced).length, 1, "cross-section references use one semantic Markdown renderer");
const nestedFence = `1. item\n\n ${"long paragraph ".repeat(1_000)}\n\n \`\`\`text\n code\n \`\`\`\n\n2. next\n`;
const nestedChunks = splitStableMarkdownSections(nestedFence);
eq(nestedChunks.length, 1, "list containers stay in one semantic Markdown renderer");
const renderMarkdown = (source: string) => renderToStaticMarkup(<ReactMarkdown>{source}</ReactMarkdown>);
eq(
nestedChunks.map(renderMarkdown).join(""),
renderMarkdown(nestedFence),
"stable Markdown optimization preserves nested list and fence DOM semantics",
);
}
{
eq(streamingMarkdownCommitInterval(1_000), 50, "short streaming Markdown uses the 50ms parse budget");
eq(streamingMarkdownCommitInterval(8_000), 150, "medium streaming Markdown uses the 150ms parse budget");
eq(streamingMarkdownCommitInterval(32_000), 300, "long streaming Markdown uses the 300ms parse budget");
}
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
let nextFrameID = 1;
let pendingFrame: FrameRequestCallback | undefined;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
pendingFrame = callback;
return nextFrameID++;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = (() => {
pendingFrame = undefined;
}) as typeof cancelAnimationFrame;
const root = createRoot(rootEl);
function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
return <div>{useRenderedMarkdownText(text, streaming)}</div>;
}
await act(async () => {
root.render(<MarkdownTextProbe text="start" streaming />);
await flushTimers();
});
eq(rootEl.textContent, "start", "streaming Markdown starts from the current text");
await act(async () => {
root.render(<MarkdownTextProbe text="start middle" streaming />);
await flushTimers();
root.render(<MarkdownTextProbe text="start middle end" streaming />);
await new Promise((resolve) => setTimeout(resolve, 60));
});
eq(rootEl.textContent, "start", "streaming Markdown holds intermediate text until the animation frame");
const frame = pendingFrame;
await act(async () => {
frame?.(performance.now());
await flushTimers();
});
eq(rootEl.textContent, "start middle end", "one animation frame commits the latest streamed text");
pendingFrame = undefined;
await act(async () => {
root.render(<MarkdownTextProbe text="start middle end later" streaming />);
await flushTimers();
});
eq(pendingFrame, undefined, "streaming Markdown waits for a fresh budget after the previous DOM commit");
await act(async () => {
root.render(<MarkdownTextProbe text="complete" streaming={false} />);
});
eq(rootEl.textContent, "complete", "short stream finalization still commits immediately");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
let pendingIdle: (() => void) | undefined;
Object.defineProperty(dom.window, "requestIdleCallback", {
configurable: true,
value: (callback: () => void) => {
pendingIdle = callback;
return 1;
},
});
Object.defineProperty(dom.window, "cancelIdleCallback", {
configurable: true,
value: () => {
pendingIdle = undefined;
},
});
Object.defineProperty(dom.window, "setTimeout", {
configurable: true,
value: (callback: TimerHandler) => {
if (typeof callback === "function") callback();
return 1;
},
});
Object.defineProperty(dom.window, "clearTimeout", {
configurable: true,
value: () => undefined,
});
const root = createRoot(rootEl);
const streamed = "a".repeat(8_100);
const finalText = `${streamed} final`;
function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
return <div>{useRenderedMarkdownText(text, streaming)}</div>;
}
await act(async () => {
root.render(<MarkdownTextProbe text={streamed} streaming />);
await flushTimers();
});
await act(async () => {
root.render(<MarkdownTextProbe text={finalText} streaming={false} />);
await flushTimers();
});
eq(rootEl.textContent, streamed, "large Markdown keeps its committed content while finalization waits for idle");
ok(Boolean(pendingIdle), "large Markdown schedules one idle finalization callback");
await act(async () => {
pendingIdle?.();
await flushTimers();
});
eq(rootEl.textContent, finalText, "idle finalization commits the complete large Markdown text");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
Object.defineProperty(dom.window, "runtime", { configurable: true, value: {} });
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
import { parsePastedBlocks } from "../components/Message";
import { parsePastedBlocks, parseSelectedTextBlocks } from "../components/Message";
import { formatSelectedTextContext, formatSelectionLabel, type SelectedTextReference } from "../lib/selectedTextContext";
let passed = 0;
let failed = 0;
@@ -35,5 +36,41 @@ for (const [label, name] of [
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
const repeatedPrefix = "same prefix selected text that is deliberately longer than forty characters";
const selections: SelectedTextReference[] = [
{ id: "chat-1", text: `${repeatedPrefix} first` },
{ id: "chat-2", text: `${repeatedPrefix} second` },
{ id: "code-1", path: "src/a]b.ts", text: "if (ready) {\n run_task();\n}" },
];
const labels = selections.map(formatSelectionLabel);
const authoredLabel = labels[0];
const display = `compare ${authoredLabel}\n${labels.join(" ")}`;
const selectedBlocks = parseSelectedTextBlocks(display, formatSelectedTextContext(selections));
eq(labels[2].includes("a]b.ts"), true, "selection labels sanitize closing brackets in file names");
eq(
selectedBlocks.map(({ label, content, path, kind }) => ({ label, content, path, kind })),
[
{ label: labels[0], content: `${repeatedPrefix} first`, kind: "chat" },
{ label: labels[1], content: `${repeatedPrefix} second`, kind: "chat" },
{ label: labels[2], content: "if (ready) {\n run_task();\n}", path: "src/a]b.ts", kind: "code" },
],
"selection cards recover duplicate snippets and bracketed file names from the existing JSON context",
);
eq(selectedBlocks[0]?.start, display.indexOf(labels[0], display.indexOf("\n") + 1), "label-shaped authored prose is not consumed as a selection card");
const unterminatedDisplay = `explain literal [Chat: ${labels.join(" ")}`;
let expectedLabelStart = unterminatedDisplay.length - labels.join(" ").length;
const expectedTrailingLabels = labels.map((label) => {
const expected = { label, start: expectedLabelStart };
expectedLabelStart += label.length + 1;
return expected;
});
eq(
parseSelectedTextBlocks(unterminatedDisplay, formatSelectedTextContext(selections)).map(({ label, start }) => ({ label, start })),
expectedTrailingLabels,
"unterminated label-shaped prose cannot consume the exact trailing selection labels",
);
eq(parsePastedBlocks(labels.join(" "), formatSelectedTextContext(selections)), [], "selection labels no longer use pasted-text marker parsing");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,316 @@
// Run: tsx src/__tests__/model-switcher-refresh.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { ModelSwitcher } from "../components/ModelSwitcher";
import { LocaleProvider } from "../lib/i18n";
import type { ModelInfo } from "../lib/types";
class TestResizeObserver {
observe() {}
disconnect() {}
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
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.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
const stale = deferred<ModelInfo[]>();
const fresh = deferred<ModelInfo[]>();
let calls = 0;
const picked: string[] = [];
const pickGates: Array<ReturnType<typeof deferred<boolean>>> = [];
let catalogLoader: (() => Promise<ModelInfo[]>) | undefined;
let currentCatalog: ModelInfo[] = [
{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
];
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
main: {
App: {
ModelsForTab: async () => {
calls += 1;
if (calls === 1) return stale.promise;
if (calls === 2) return fresh.promise;
if (catalogLoader) return catalogLoader();
return currentCatalog;
},
},
},
};
const root = createRoot(document.getElementById("root")!);
const renderSwitcher = (label: string, tabId: string) => (
<LocaleProvider>
<ModelSwitcher
label={label}
tabId={tabId}
onPick={(ref) => {
picked.push(ref);
return pickGates.shift()?.promise ?? Promise.resolve(true);
}}
/>
</LocaleProvider>
);
await act(async () => {
root.render(renderSwitcher("deepseek-v4-flash", "tab-a"));
});
await act(async () => {
window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
fresh.resolve([{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }]);
await fresh.promise;
});
await act(async () => {
stale.resolve([{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: true }]);
await stale.promise;
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const options = Array.from(document.querySelectorAll<HTMLElement>("[role='option']")).map((item) => item.textContent?.trim());
if (JSON.stringify(options) !== JSON.stringify(["glm-5.2"])) {
throw new Error(`model catalog did not keep the fresh result: ${JSON.stringify(options)}`);
}
if (calls < 3) throw new Error(`expected mount, settings refresh, and open loads; got ${calls}`);
await act(async () => {
(document.querySelector("[role='option'][aria-selected='true']") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
if (picked.length !== 0) throw new Error(`current model should be a no-op, picked ${JSON.stringify(picked)}`);
if ((document.querySelector(".modelsw__trigger") as HTMLButtonElement).getAttribute("aria-expanded") !== "false") {
throw new Error("current-model no-op did not close the menu");
}
currentCatalog = [
{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: false },
];
const pendingPickGate = deferred<boolean>();
pickGates.push(pendingPickGate, pendingPickGate);
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const next = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
.find((option) => option.textContent?.includes("deepseek-v4-flash"));
if (!next) throw new Error("pending-switch model option did not load");
await act(async () => {
next.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
if (picked[0] !== "deepseek/deepseek-v4-flash") {
throw new Error(`non-current model was not selected: ${JSON.stringify(picked)}`);
}
// The backend catalog may still identify the outgoing GLM model as current
// while the first switch is pending. Selecting it now must enqueue a rollback.
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const rollback = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
if (!rollback) throw new Error("pending-switch rollback option did not load");
await act(async () => {
rollback.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
if (picked[1] !== "glm-cn/glm-5.2") {
throw new Error(`pending current-model rollback was swallowed: ${JSON.stringify(picked)}`);
}
// Pending work belongs to tab A. Reusing the mounted switcher for tab B must
// not turn B's settled current model into another rollback request.
currentCatalog = [
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: true },
];
await act(async () => {
root.render(renderSwitcher("model-b", "tab-b"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const tabBCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
if (!tabBCurrent) throw new Error("tab B current model did not load");
await act(async () => {
tabBCurrent.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
if (picked.length !== 2) {
throw new Error(`tab A pending picks leaked into tab B: ${JSON.stringify(picked)}`);
}
await act(async () => {
pendingPickGate.resolve(true);
await pendingPickGate.promise;
});
// A failed latest pick must immediately roll back its optimistic selection,
// then reconcile from the authoritative backend catalog. Retrying the same
// target before that catalog request completes must remain possible.
currentCatalog = [
{ ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
];
await act(async () => {
root.render(renderSwitcher("model-a", "tab-c"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const failedTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
.find((option) => option.textContent?.includes("model-b"));
if (!failedTarget) throw new Error("failed-switch target did not load");
const failedPickGate = deferred<boolean>();
pickGates.push(failedPickGate);
const catalogReloadGate = deferred<ModelInfo[]>();
catalogLoader = () => catalogReloadGate.promise;
await act(async () => {
failedTarget.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
failedPickGate.resolve(false);
await act(async () => {
await failedPickGate.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const rolledBackCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
if (!rolledBackCurrent?.textContent?.includes("model-a")) {
throw new Error(`failed pick did not immediately roll back: ${rolledBackCurrent?.textContent ?? "missing"}`);
}
const retryTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
.find((option) => option.textContent?.includes("model-b"));
if (!retryTarget) throw new Error("failed-switch target was not available for immediate retry");
const retryPickGate = deferred<boolean>();
pickGates.push(retryPickGate);
await act(async () => {
retryTarget.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
if (picked.filter((ref) => ref === "provider-b/model-b").length !== 2) {
throw new Error(`failed target could not be retried: ${JSON.stringify(picked)}`);
}
// The failed attempt's catalog request describes the pre-retry backend state.
// Its late completion must not overwrite the newer optimistic retry.
catalogReloadGate.resolve(currentCatalog);
await act(async () => {
await catalogReloadGate.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
const retryTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
.getAttribute("aria-label") ?? "";
if (!retryTriggerLabel.includes("provider-b")) {
throw new Error(`stale failure reconciliation overwrote the retry: ${retryTriggerLabel}`);
}
retryPickGate.resolve(true);
await act(async () => {
await retryPickGate.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
catalogLoader = undefined;
// An older superseded failure also must not roll back a newer pending pick.
currentCatalog = [
{ ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
{ ref: "provider-c/model-c", provider: "provider-c", model: "model-c", current: false },
];
await act(async () => {
root.render(renderSwitcher("model-a", "tab-d"));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const olderTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
.find((option) => option.textContent?.includes("model-b"));
if (!olderTarget) throw new Error("older switch target did not load");
const olderPickGate = deferred<boolean>();
pickGates.push(olderPickGate);
await act(async () => {
olderTarget.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const newerTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
.find((option) => option.textContent?.includes("model-c"));
if (!newerTarget) throw new Error("newer switch target did not load");
const newerPickGate = deferred<boolean>();
pickGates.push(newerPickGate);
await act(async () => {
newerTarget.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
olderPickGate.resolve(false);
await act(async () => {
await olderPickGate.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
const newerTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
.getAttribute("aria-label") ?? "";
if (!newerTriggerLabel.includes("provider-c")) {
throw new Error(`superseded failure rolled back the newer pick: ${newerTriggerLabel}`);
}
newerPickGate.resolve(true);
await act(async () => {
await newerPickGate.promise;
});
await act(async () => root.unmount());
console.log("model switcher refresh: PASS");
@@ -5,7 +5,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { initialState, reducer, useController, type Item } from "../lib/useController";
import type { AppBindings } from "../lib/bridge";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -139,7 +139,12 @@ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.win
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const staleHistory = deferred<HistoryMessage[]>();
const staleSessionMeta = deferred<Meta>();
let newSessionCalls = 0;
let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }];
let holdNextMeta = false;
let staleMetaStarted = false;
const eventHandlers: Array<(event: WireEvent) => void> = [];
const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
@@ -147,14 +152,24 @@ const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
window.runtime = {
EventsOn: () => () => {},
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
if (name === "agent:event") eventHandlers.push(cb as (event: WireEvent) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => [tabMeta()],
MetaForTab: async () => meta(),
MetaForTab: async () => {
if (holdNextMeta) {
holdNextMeta = false;
staleMetaStarted = true;
return staleSessionMeta.promise;
}
return meta({ canonicalTodos: backendCanonicalTodos });
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
@@ -169,10 +184,22 @@ window.go = {
ReplayPendingPrompts: async () => {},
NewSession: async () => {
newSessionCalls += 1;
backendCanonicalTodos = [];
},
NewSessionForTab: async (tabID: string) => {
if (tabID !== "tab-a") throw new Error(`unexpected new-session target ${tabID}`);
newSessionCalls += 1;
backendCanonicalTodos = [];
},
ResumeSessionPageForTab: async () => {
backendCanonicalTodos = [{ content: "Restored task", status: "completed" }];
return {
messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }],
startTurn: 0,
endTurn: 1,
totalTurns: 1,
hasOlder: false,
};
},
} as Partial<AppBindings> as AppBindings,
},
@@ -196,12 +223,33 @@ await act(async () => {
});
await waitFor("active tab", () => controller?.activeTabId === "tab-a");
await act(async () => {
await controller?.refreshMeta();
await flushPromises();
});
eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Old task", "pre-reset metadata exposes the current session todo");
holdNextMeta = true;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a" });
await flushPromises();
});
await waitFor("stale metadata request", () => staleMetaStarted);
await act(async () => {
await controller?.newSession();
await flushPromises();
});
eq(newSessionCalls, 1, "tab-scoped NewSession is called once");
eq(controller?.state.items.length, 0, "new session clears the visible transcript");
eq(controller?.state.meta?.canonicalTodos?.length, 0, "new session refresh replaces the previous session todo with an authoritative empty list");
await act(async () => {
staleSessionMeta.resolve(meta({ canonicalTodos: [{ content: "Old task", status: "in_progress" }] }));
await staleSessionMeta.promise;
await flushPromises();
});
eq(controller?.state.meta?.canonicalTodos?.length, 0, "metadata started before a session transition cannot restore the previous todo");
await act(async () => {
staleHistory.resolve([{ role: "user", content: "old prompt" }]);
@@ -211,10 +259,156 @@ await act(async () => {
eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
await act(async () => {
await controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
await flushPromises();
});
eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
await act(async () => {
root.unmount();
});
// Reusing a blank tab must invalidate the old hydration request. The backend
// may return the same tab id, so the request sequence (not the tab id) is the
// session boundary that prevents orphaned tool cards from coming back.
const reusedOldHistory = deferred<{
messages: HistoryMessage[];
startTurn: number;
endTurn: number;
totalTurns: number;
hasOlder: boolean;
}>();
const reusedHistoryCalls: string[] = [];
const reusedTab = tabMeta({ id: "tab-reused", sessionPath: "/sessions/old.jsonl" });
const reusedTabPage = {
messages: [
{ role: "assistant", content: "", toolCalls: [{ id: "old-call", name: "bash", arguments: "pwd" }] },
{ role: "tool", toolCallId: "old-call", toolName: "bash", content: "/old" },
] as HistoryMessage[],
startTurn: 0,
endTurn: 0,
totalTurns: 0,
hasOlder: false,
};
const reusedEmptyPage = { messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false };
window.go.main.App = {
ListTabs: async () => [reusedTab],
MetaForTab: async () => meta({ sessionPath: "/sessions/new.jsonl" }),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryPageForTab: async () => {
reusedHistoryCalls.push("history");
return reusedHistoryCalls.length === 1 ? reusedOldHistory.promise : reusedEmptyPage;
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
EnsureBlankTab: async () => ({ ...reusedTab, sessionPath: "/sessions/new.jsonl", active: true }),
} as Partial<AppBindings> as AppBindings;
controller = undefined;
const reuseRoot = createRoot(rootEl);
await act(async () => {
reuseRoot.render(<Probe />);
await flushPromises();
});
await waitFor("reused tab startup history", () => reusedHistoryCalls.length === 1);
await act(async () => {
await controller?.ensureBlankTab("project", "/repo");
await flushPromises();
});
eq(reusedHistoryCalls.length, 2, "reusing a blank tab forces a fresh history request");
eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "fresh blank-tab hydration has no old tool card");
await act(async () => {
reusedOldHistory.resolve(reusedTabPage);
await reusedOldHistory.promise;
await flushPromises();
});
eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "late old-session history cannot restore an orphaned tool card");
await act(async () => {
reuseRoot.unmount();
});
// A tab-bar click can overtake EnsureBlankTab while its backend call is still
// in flight. Its intent must invalidate the older completion immediately, and
// the stale backend activation must be repaired after it eventually returns.
const queuedBlank = deferred<TabMeta>();
const raceTabA = tabMeta({ id: "race-a", active: true, sessionPath: "/sessions/race-a.jsonl" });
const raceTabB = tabMeta({ id: "race-b", active: false, sessionPath: "/sessions/race-b.jsonl" });
const raceBlank = tabMeta({ id: "race-blank", active: false, sessionPath: "/sessions/race-blank.jsonl" });
let raceBackendActiveId = raceTabA.id;
const raceHistoryCalls: string[] = [];
const raceSetActiveCalls: string[] = [];
window.go.main.App = {
ListTabs: async () => [raceTabA, raceTabB, raceBlank].map((tab) => ({ ...tab, active: tab.id === raceBackendActiveId })),
MetaForTab: async (tabID: string) => meta({ sessionPath: `/sessions/${tabID}.jsonl` }),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryPageForTab: async (tabID: string) => {
raceHistoryCalls.push(tabID);
return reusedEmptyPage;
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
EnsureBlankTab: async () => {
const tab = await queuedBlank.promise;
raceBackendActiveId = tab.id;
return tab;
},
SetActiveTab: async (tabID: string) => {
raceSetActiveCalls.push(tabID);
raceBackendActiveId = tabID;
},
} as Partial<AppBindings> as AppBindings;
controller = undefined;
const queuedRaceRoot = createRoot(rootEl);
await act(async () => {
queuedRaceRoot.render(<Probe />);
await flushPromises();
});
await waitFor("queued blank race startup", () => controller?.activeTabId === raceTabA.id);
let pendingBlank: Promise<TabMeta> | undefined;
await act(async () => {
pendingBlank = controller?.ensureBlankTab("project", "/repo");
await flushPromises();
});
const tabClickIntent = controller?.noteNavigationIntent();
if (tabClickIntent === undefined) throw new Error("missing queued tab intent");
let pendingTabSwitch: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
pendingTabSwitch = controller?.switchTab(raceTabB.id, raceTabB, tabClickIntent);
await pendingTabSwitch;
await flushPromises();
});
eq(controller?.activeTabId, raceTabB.id, "queued tab click becomes visible before the older blank completion");
eq(raceBackendActiveId, raceTabB.id, "queued tab click becomes backend-active before the older blank completion");
await act(async () => {
queuedBlank.resolve({ ...raceBlank, active: true });
await pendingBlank;
await flushPromises();
});
eq(controller?.activeTabId, raceTabB.id, "late blank completion cannot replace the newer visible tab");
eq(raceHistoryCalls.includes(raceBlank.id), false, "stale blank completion does not hydrate the abandoned tab");
eq(raceBackendActiveId, raceTabB.id, "late blank completion reasserts the newer backend-active tab");
eq(raceSetActiveCalls.join(","), `${raceTabB.id},${raceTabB.id}`, "stale blank completion repairs backend focus exactly once");
await act(async () => {
queuedRaceRoot.unmount();
});
const guardedStartupTabs = deferred<TabMeta[]>();
const staleProjectA = "/repo/project-a";
const targetProjectB = "/repo/project-b";
@@ -391,6 +391,9 @@ const rebuiltHandlers: Array<(tabId?: string) => void> = [];
let holdNextListTabs: Promise<void> | undefined;
let modeDrain: ReturnType<typeof deferred<string[]>> | undefined;
let toolApprovalModeDrain: ReturnType<typeof deferred<string[]>> | undefined;
let composerProfileDrain: ReturnType<typeof deferred<string[]>> | undefined;
let composerProfileCalls = 0;
let rejectNextComposerProfile = false;
window.runtime = {
EventsOn: (name: string, cb: (payload: unknown) => void) => {
@@ -424,6 +427,14 @@ window.go = {
SetActiveTab: async () => {},
SetModeForTab: async () => modeDrain?.promise ?? [],
SetToolApprovalModeForTab: async () => toolApprovalModeDrain?.promise ?? [],
SetComposerProfileForTab: async () => {
composerProfileCalls += 1;
if (rejectNextComposerProfile) {
rejectNextComposerProfile = false;
throw new Error("profile transaction failed");
}
return composerProfileDrain?.promise ?? [];
},
} as Partial<AppBindings> as AppBindings,
},
};
@@ -581,6 +592,100 @@ eq(controller?.state.running, false, "fresh idle snapshot releases the blocked s
await flushPromises();
});
eq(controller?.state.approval?.subject, "new tool-approval prompt", "a late SetToolApprovalModeForTab drain cannot dismiss a new same-id prompt");
const profileApprovalID = "profile-drain-1";
await act(async () => {
for (const handler of eventHandlers) {
handler({ kind: "approval_request", tabId: "tab-a", approval: { id: profileApprovalID, tool: "bash", subject: "old composer-profile prompt" } } as WireEvent);
}
await flushPromises();
});
composerProfileDrain = deferred<string[]>();
const profileCallsBefore = composerProfileCalls;
let profilePromise: Promise<boolean> | undefined;
await act(async () => {
profilePromise = controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await flushPromises();
});
eq(composerProfileCalls, profileCallsBefore + 1, "one composer-profile sync uses one atomic backend call");
await act(async () => {
for (const handler of rebuiltHandlers) handler("tab-a");
for (const handler of eventHandlers) {
handler({ kind: "approval_request", tabId: "tab-a", approval: { id: profileApprovalID, tool: "bash", subject: "new composer-profile prompt" } } as WireEvent);
}
await flushPromises();
});
await act(async () => {
composerProfileDrain?.resolve([profileApprovalID]);
await profilePromise;
await flushPromises();
});
eq(controller?.state.approval?.subject, "new composer-profile prompt", "a late atomic profile drain cannot dismiss a new same-id prompt");
composerProfileDrain = undefined;
const rebuiltProfileCallsBefore = composerProfileCalls;
await act(async () => {
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
await flushPromises();
});
eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per rebuilt controller generation");
const concurrentProfileDrain = deferred<string[]>();
composerProfileDrain = concurrentProfileDrain;
const changedProfileCallsBefore = composerProfileCalls;
let concurrentProfileA: Promise<boolean> | undefined;
let concurrentProfileB: Promise<boolean> | undefined;
await act(async () => {
concurrentProfileA = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "new goal");
concurrentProfileB = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "new goal");
await flushPromises();
});
eq(composerProfileCalls, changedProfileCallsBefore + 1, "concurrent identical profile replays share one backend call");
await act(async () => {
concurrentProfileDrain.resolve([]);
await Promise.all([concurrentProfileA, concurrentProfileB]);
await flushPromises();
});
const olderProfileDrain = deferred<string[]>();
const latestProfileDrain = deferred<string[]>();
composerProfileDrain = olderProfileDrain;
const orderedProfileCallsBefore = composerProfileCalls;
let olderProfile: Promise<boolean> | undefined;
let latestProfile: Promise<boolean> | undefined;
await act(async () => {
olderProfile = controller?.setComposerProfileForTab("tab-a", "plan", "yolo", "older intent");
latestProfile = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "latest intent");
await flushPromises();
});
eq(composerProfileCalls, orderedProfileCallsBefore + 1, "different composer profiles are serialized per tab");
composerProfileDrain = latestProfileDrain;
await act(async () => {
olderProfileDrain.resolve([]);
await olderProfile;
await flushPromises();
});
eq(composerProfileCalls, orderedProfileCallsBefore + 2, "latest composer profile starts after the older transaction");
await act(async () => {
latestProfileDrain.resolve([]);
await latestProfile;
await flushPromises();
});
composerProfileDrain = undefined;
rejectNextComposerProfile = true;
const retryCallsBefore = composerProfileCalls;
let failedProfile = true;
let retriedProfile = false;
await act(async () => {
failedProfile = await controller!.setComposerProfileForTab("tab-a", "plan", "ask", "retry goal");
retriedProfile = await controller!.setComposerProfileForTab("tab-a", "plan", "ask", "retry goal");
await flushPromises();
});
eq(failedProfile, false, "failed composer profile transaction blocks the caller");
eq(retriedProfile, true, "failed composer profile transaction remains retryable");
eq(composerProfileCalls, retryCallsBefore + 2, "failed profile key is not cached as applied");
}
await act(async () => {
File diff suppressed because it is too large. Load diff
@@ -3,8 +3,13 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { initialState, reducer, replayPendingPromptsForActiveTab } from "../lib/useController";
import { acceptsRuntimeEventEpoch, initialState, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
import { continueDelivery } from "../lib/deliveryContinue";
import {
activateGoalAndSubmit,
activateGoalAndSubmitOnTab,
workbenchTargetToken,
} from "../lib/goalSubmit";
import type { WireEvent } from "../lib/types";
let passed = 0;
@@ -22,6 +27,111 @@ function eq(a: unknown, b: unknown, label: string) {
console.log("\nsend failure feedback");
eq(
workbenchTargetToken({ kind: "local", identityGen: 1, requestSeq: 0 })?.requestSeq,
0,
"initial Local workbench target keeps its zero request sequence",
);
eq(
workbenchTargetToken({ kind: "local", identityGen: 1 }),
null,
"workbench target without a request sequence fails closed",
);
{
const calls: string[] = [];
await activateGoalAndSubmit({
displayText: "List the existing notes",
submitText: "/ui-ux-pro-max List the existing notes",
structured: {
display: "/ui-ux-pro-max List the existing notes",
input: "List the existing notes",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
applyGoal: async (goal) => {
calls.push(`goal:${goal}`);
},
send: async (display, submit, structured) => {
calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`);
},
});
eq(calls.join("|"), "goal:List the existing notes|send:List the existing notes:/ui-ux-pro-max List the existing notes:ui-ux-pro-max", "initial Goal activates before structured Skill submission");
}
{
// Bridge failure must abort structured Skill submit: there is no `/goal` fallback.
const calls: string[] = [];
let threw = false;
try {
await activateGoalAndSubmit({
displayText: "Ship the feature",
submitText: "/ui-ux-pro-max Ship the feature",
structured: {
display: "/ui-ux-pro-max Ship the feature",
input: "Ship the feature",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
applyGoal: async (goal) => {
calls.push(`goal:${goal}`);
throw new Error("SetGoalForTab: tab closed");
},
send: async (display, submit, structured) => {
calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`);
},
});
} catch (error) {
threw = error instanceof Error && error.message === "SetGoalForTab: tab closed";
}
eq(threw, true, "Goal activation bridge failure propagates");
eq(calls.join("|"), "goal:Ship the feature", "failed Goal activation does not submit the structured Skill");
}
{
// Tab-scoped helper captures source tab and workbench target once; callbacks
// receive both even if a surrounding "active tab" concept changes mid-flight.
const calls: string[] = [];
let releaseSubmit!: () => void;
const submitGate = new Promise<void>((resolve) => {
releaseSubmit = resolve;
});
let activeTab = "tab-a";
const pending = activateGoalAndSubmitOnTab({
tabId: "tab-a",
target: { kind: "ssh", identityGen: 7, requestSeq: 11 },
displayText: "Cross-tab safe goal",
submitText: "/ui-ux-pro-max Cross-tab safe goal",
structured: {
display: "/ui-ux-pro-max Cross-tab safe goal",
input: "Cross-tab safe goal",
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
},
sendToTab: async (tabId, goal, display, submit, structured, target) => {
await submitGate;
calls.push(
`send:${tabId}:${goal}:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}:${target?.kind}:${target?.identityGen}:${target?.requestSeq}:active=${activeTab}`,
);
},
});
activeTab = "tab-b";
calls.push("switched-to-tab-b");
releaseSubmit();
await pending;
eq(
calls.join("|"),
"switched-to-tab-b|send:tab-a:Cross-tab safe goal:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:ssh:7:11:active=tab-b",
"activateGoalAndSubmitOnTab keeps Goal and Skill on the captured source tab and target",
);
}
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "starting", epoch: "e1" } }), false, "starting runtime cannot submit");
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "lease_blocked", epoch: "e1" } }), false, "lease-blocked runtime cannot submit");
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit");
eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit");
eq(acceptsRuntimeEventEpoch("e2", "e1"), false, "old runtime epoch is rejected");
eq(acceptsRuntimeEventEpoch("e2", "e2"), true, "current runtime epoch is accepted");
eq(acceptsRuntimeEventEpoch(undefined, "e1"), true, "first runtime epoch can establish the fence");
eq(acceptsRuntimeEventEpoch("e2", undefined), true, "legacy events remain compatible");
const sent = reducer({ ...initialState }, { type: "user", text: "hello", seq: 0 });
eq(sent.items.length, 1, "submit appends the user bubble immediately");
eq(sent.items[0].kind === "user" && sent.items[0].text, "hello", "bubble carries the submitted text");
@@ -93,6 +203,31 @@ const ordinaryTurnNotice = ordinaryTurnError.items[ordinaryTurnError.items.lengt
eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.level, "warn", "ordinary turn errors remain warnings");
eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.text, "provider failed", "ordinary turn errors keep their diagnostic text");
const recoveryPaused = reducer(readinessStarted, {
type: "event",
e: {
kind: "turn_done",
outcome: "recovery_paused",
err: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction.",
} as WireEvent,
});
const recoveryNotice = recoveryPaused.items[recoveryPaused.items.length - 1];
eq(recoveryNotice.kind === "notice" && recoveryNotice.level, "info", "recovery_paused uses informational severity");
eq(recoveryNotice.kind === "notice" && Boolean(recoveryNotice.title), true, "recovery_paused shows a product title");
eq(
recoveryNotice.kind === "notice" && recoveryNotice.text,
"Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.",
"recovery_paused uses the localized product copy",
);
eq(
recoveryNotice.kind === "notice" && Boolean(recoveryNotice.detail),
false,
"recovery_paused does not repeat the backend English fallback as localized detail",
);
const recoveryUser = recoveryPaused.items.find((it) => it.kind === "user");
eq(recoveryUser?.kind === "user" && Boolean(recoveryUser.failed), false, "recovery_paused does not mark the user message as failed");
eq(recoveryPaused.running, false, "recovery_paused frees the composer");
const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0 });
const shellFailed = reducer(shellSent, { type: "send_failed", error: "Command failed: workspace is still starting" });
const shellNotice = shellFailed.items[shellFailed.items.length - 1];
@@ -126,6 +261,11 @@ eq(
true,
"plan approval clears the remembered plan restore intent before execution",
);
eq(
/onExitPlan=\{async \(\) => \{\s*await applyCollaborationMode\("normal"\);\s*approve\(state\.approval!\.id, false, false, false\);\s*\}\}/.test(appSource),
true,
"exit-without-executing switches to Normal before rejecting the pending plan",
);
eq(
!/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource),
true,
@@ -147,19 +287,51 @@ eq(
"failed runtime profile transitions roll back the optimistic token mode",
);
eq(
appSource.includes("!state.backendActivationPending && !runtimeTransitioning"),
appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"),
true,
"runtime profile transitions keep submit behind the controller-ready gate",
);
eq(
appSource.includes("activateGoalAndSubmitOnTab({") &&
appSource.includes("tabId: sourceTabId") &&
appSource.includes("target: sourceTarget") &&
appSource.includes("target ? {") &&
appSource.includes("goal: nextGoal") &&
appSource.includes("collaborationMode: controllerComposerProfileCollaborationMode(composerProfile)") &&
appSource.includes("toolApprovalMode,"),
true,
"initial Goal activation captures the submission tab and workbench target",
);
eq(
appSource.includes("setControllerGoalForTab(tabId, trimmed)") && appSource.includes("clearControllerGoalForTab(tabId)"),
true,
"tab-scoped Goal activation updates the matching controller",
);
eq(
/await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource),
true,
"local Goal profile is patched only after backend activation succeeds",
);
eq(
controllerSource.includes("await app.SetGoalForTab(tabId, goal)") && !/SetGoalForTab\(tabId, goal\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
true,
"SetGoalForTab activation failures propagate to callers",
);
eq(
controllerSource.includes("await app.ClearGoalForTab(tabId)") && !/ClearGoalForTab\(tabId\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
true,
"ClearGoalForTab failures also propagate to callers",
);
eq(
/await continueDelivery\(\{[\s\S]{0,240}goal: state\.meta\?\.goal,[\s\S]{0,240}resumeGoal: resumeControllerGoalForTab,/.test(appSource),
true,
"delivery recovery routes through continueDelivery with the backend Goal state",
);
eq(
/await applyGoal\(trimmed\);[\s\S]{0,120}await commitThenSendRef\.current\(sourceTabId/.test(appSource),
controllerSource.includes("app.SubmitInitialGoalToTab(") &&
appSource.includes("patchActivatedGoalForTab(sourceTabId, trimmed)"),
true,
"the first Goal turn waits for the controller Goal before submitting",
"the first Goal turn uses the atomic target-scoped backend contract",
);
const unsent = reducer(sent, { type: "unsend" });
@@ -16,6 +16,11 @@ import {
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { SettingsView } from "../lib/types";
import {
applyTypographyPreferences,
createDefaultTypographyPreferences,
getTypographyPreferences,
} from "../lib/typographyPreferences";
let passed = 0;
let failed = 0;
@@ -134,6 +139,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
defaultToolApprovalMode: "auto",
checkUpdates: true,
updateChannel: "stable",
telemetry: true,
metrics: true,
configPath: "/tmp/reasonix/config.toml",
@@ -201,6 +207,16 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
window.scrollTo = () => {};
localStorage.clear();
const regionalTypography = createDefaultTypographyPreferences();
regionalTypography.code = {
followGlobal: false,
fontFamily: "jetbrains",
customFontName: "",
fontSize: 15,
};
applyTypographyPreferences(regionalTypography);
const regionalCodeFont = document.documentElement.style.getPropertyValue("--typography-code-font");
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
let settingsCalls = 0;
let setDisplayModeCalls = 0;
@@ -386,6 +402,23 @@ await act(async () => {
});
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
const monoFontSelect = zoomRootEl.querySelector("select[aria-labelledby='appearance-mono-font-family-label']") as HTMLSelectElement | null;
if (!monoFontSelect) throw new Error("monospace font selector did not render");
await act(async () => {
monoFontSelect.value = "custom";
monoFontSelect.dispatchEvent(new Event("change", { bubbles: true }));
await flushPromises();
});
const preservedTypography = getTypographyPreferences();
eq(preservedTypography.code.followGlobal, false, "global monospace changes preserve an explicit code-region override");
eq(preservedTypography.code.fontFamily, "jetbrains", "global monospace changes preserve the regional code font choice");
eq(
document.documentElement.style.getPropertyValue("--typography-code-font"),
regionalCodeFont,
"global monospace changes keep the regional code font CSS variable",
);
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
await act(async () => {
@@ -86,6 +86,19 @@ async function main() {
const keyButton = container.querySelector<HTMLButtonElement>(".shortcuts-settings__key");
ok(Boolean(keyButton), "recorder button renders");
if (!keyButton) throw new Error("no recorder button");
const undoButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.undo"]');
const redoButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.redo"]');
ok(Boolean(undoButton), "composer undo appears in shortcut settings");
ok(Boolean(redoButton), "composer redo appears in shortcut settings");
ok(Boolean(undoButton?.disabled), "composer undo stays locked to the native editing chord");
ok(Boolean(redoButton?.disabled), "composer redo stays locked to the native editing chord");
ok(undoButton?.textContent?.includes("Ctrl") === true && undoButton.textContent.includes("Z"), "composer undo shows Ctrl+Z on non-mac platforms");
ok(
redoButton?.textContent?.includes("Ctrl") === true
&& redoButton.textContent.includes("Shift")
&& redoButton.textContent.includes("Z"),
"composer redo shows Ctrl+Shift+Z on non-mac platforms",
);
// Click WITHOUT focusing — JSDOM (like WebKit/WKWebView) does not focus
// buttons on click, so this reproduces the desktop app's event flow.
@@ -145,8 +145,12 @@ const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const setActiveEGate = deferred<void>();
const setActiveFGate = deferred<void>();
const staleSwitchFGate = deferred<void>();
const staleSwitchReassertGGate = deferred<void>();
const submitTabCGate = deferred<void>();
const forkResultGate = deferred<void>();
const staleForkResultGate = deferred<void>();
const staleForkReassertGGate = deferred<void>();
const historyCalls: string[] = [];
const cancelCalls: string[] = [];
let contextDCalls = 0;
@@ -161,6 +165,13 @@ let replayPendingPromptCalls = 0;
let failSetActiveFor = "";
let holdNextForkResult = false;
let forkStarted = false;
let holdStaleSwitchF = false;
let holdStaleSwitchReassertG = false;
let staleSwitchReassertGStarted = false;
let holdStaleForkResult = false;
let staleForkStarted = false;
let holdStaleForkReassertG = false;
let staleForkReassertGStarted = false;
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
@@ -250,7 +261,7 @@ window.go = {
return { ...tabE, active: true, running: true };
},
ForkForTab: async () => {
const fork = holdNextForkResult ? tabJ : tabE;
const fork = holdNextForkResult || holdStaleForkResult ? tabJ : tabE;
tabsById.set(fork.id, fork);
backendActiveId = fork.id;
runningTabs.add(fork.id);
@@ -259,6 +270,11 @@ window.go = {
forkStarted = true;
await forkResultGate.promise;
}
if (holdStaleForkResult) {
holdStaleForkResult = false;
staleForkStarted = true;
await staleForkResultGate.promise;
}
return { ...fork, active: true, running: true };
},
ReplayPendingPrompts: async () => {
@@ -278,6 +294,20 @@ window.go = {
if (tabID === "tab-b") await setActiveBGate.promise;
if (tabID === "tab-e") await setActiveEGate.promise;
if (tabID === "tab-f") await setActiveFGate.promise;
if (tabID === "tab-f" && holdStaleSwitchF) {
holdStaleSwitchF = false;
await staleSwitchFGate.promise;
}
if (tabID === "tab-g" && holdStaleSwitchReassertG) {
holdStaleSwitchReassertG = false;
staleSwitchReassertGStarted = true;
await staleSwitchReassertGGate.promise;
}
if (tabID === "tab-g" && holdStaleForkReassertG) {
holdStaleForkReassertG = false;
staleForkReassertGStarted = true;
await staleForkReassertGGate.promise;
}
if (tabID === failSetActiveFor) throw new Error("persist failed");
backendActiveId = tabID;
},
@@ -698,6 +728,52 @@ await waitFor("reopened tab-h finishes after stale meta discard", () =>
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
);
// A stale repair is itself asynchronous. Force a third navigation to complete
// while that repair is pending and verify the repair follows the newest tab.
holdStaleSwitchF = true;
holdStaleSwitchReassertG = true;
let threeWaySwitchToF: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
threeWaySwitchToF = controller?.switchTab("tab-f", tabF);
await flushPromises();
await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
staleSwitchFGate.resolve();
await flushPromises();
});
await waitFor("stale switch reassert G starts", () => staleSwitchReassertGStarted);
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
staleSwitchReassertGGate.resolve();
await threeWaySwitchToF;
await flushPromises();
});
eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale switch repair");
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale switch repair");
holdStaleForkResult = true;
holdStaleForkReassertG = true;
let threeWayFork: Promise<boolean> | undefined;
await act(async () => {
threeWayFork = controller?.rewindForTab("tab-h", 0, "fork");
await flushPromises();
});
await waitFor("stale fork result", () => staleForkStarted && backendActiveId === "tab-j");
await act(async () => {
await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
staleForkResultGate.resolve();
await flushPromises();
});
await waitFor("stale fork reassert G starts", () => staleForkReassertGStarted);
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
staleForkReassertGGate.resolve();
await threeWayFork;
await flushPromises();
});
eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale fork repair");
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
runningTabs.delete("tab-j");
await act(async () => {
root.unmount();
});
@@ -0,0 +1,86 @@
import { JSDOM } from "jsdom";
import { __emitMockTerminalExit, __emitMockTerminalOutput } from "../lib/bridge";
import {
__resetTerminalEventBus,
forgetTerminalSession,
registerTerminalSink,
startTerminalEventBridge,
terminalEventBufferLimit,
} from "../lib/terminalEvents";
function base64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("base64");
}
function check(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
process.stdout.write(`PASS ${message}\n`);
}
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const previousWindow = globalThis.window;
const previousAtob = globalThis.atob;
const previousBtoa = globalThis.btoa;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.atob = (value: string) => Buffer.from(value, "base64").toString("binary");
globalThis.btoa = (value: string) => Buffer.from(value, "binary").toString("base64");
try {
__resetTerminalEventBus();
startTerminalEventBridge();
const first: number[] = [];
__emitMockTerminalOutput({ id: "utf8", data: base64(new Uint8Array([0xe4, 0xbd])) });
__emitMockTerminalOutput({ id: "utf8", data: base64(new Uint8Array([0xa0])) });
const unregister = registerTerminalSink("utf8", (bytes) => first.push(...bytes));
unregister();
check(JSON.stringify(first) === JSON.stringify([0xe4, 0xbd, 0xa0]), "split UTF-8 output is delivered as original bytes");
const live: number[] = [];
const unregisterLive = registerTerminalSink("replay", (bytes) => live.push(...bytes));
__emitMockTerminalOutput({ id: "replay", data: base64(new Uint8Array([1, 2])) });
unregisterLive();
__emitMockTerminalOutput({ id: "replay", data: base64(new Uint8Array([3])) });
const replayed: number[] = [];
const unregisterReplay = registerTerminalSink("replay", (bytes) => replayed.push(...bytes));
unregisterReplay();
check(JSON.stringify(live) === JSON.stringify([1, 2]), "attached terminal receives live output");
check(JSON.stringify(replayed) === JSON.stringify([1, 2, 3]), "remounted terminal replays complete output history");
const chunk = new Uint8Array(64 * 1024);
chunk.fill(65);
for (let index = 0; index < 24; index += 1) {
__emitMockTerminalOutput({ id: "bounded", data: base64(chunk) });
}
const bounded: number[] = [];
const unregisterBounded = registerTerminalSink("bounded", (bytes) => bounded.push(...bytes));
unregisterBounded();
check(bounded.length <= terminalEventBufferLimit, "terminal output history is bounded to one MiB per session");
__emitMockTerminalOutput({ id: "forgotten", data: base64(new Uint8Array([9])) });
forgetTerminalSession("forgotten");
const forgotten: number[] = [];
const unregisterForgotten = registerTerminalSink("forgotten", (bytes) => forgotten.push(...bytes));
unregisterForgotten();
check(forgotten.length === 0, "explicitly closed terminal history is discarded");
__emitMockTerminalOutput({ id: "natural-exit", data: base64(new Uint8Array([7])) });
__emitMockTerminalExit({ id: "natural-exit", exitCode: 0 });
const naturalExit: number[] = [];
const unregisterNaturalExit = registerTerminalSink("natural-exit", (bytes) => naturalExit.push(...bytes));
unregisterNaturalExit();
check(JSON.stringify(naturalExit) === JSON.stringify([7]), "natural exit preserves terminal history");
__emitMockTerminalOutput({ id: "removed-exit", data: base64(new Uint8Array([8])) });
__emitMockTerminalExit({ id: "removed-exit", exitCode: 0, removed: true });
const removedExit: number[] = [];
const unregisterRemovedExit = registerTerminalSink("removed-exit", (bytes) => removedExit.push(...bytes));
unregisterRemovedExit();
check(removedExit.length === 0, "removed terminal exit discards terminal history");
} finally {
__resetTerminalEventBus();
if (previousWindow) globalThis.window = previousWindow;
else delete (globalThis as { window?: unknown }).window;
if (previousAtob) globalThis.atob = previousAtob;
if (previousBtoa) globalThis.btoa = previousBtoa;
}
@@ -0,0 +1,25 @@
import { formatTerminalOutputForComposer, stripTerminalControlSequences } from "../lib/terminalOutput";
function eq<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) throw new Error(`${message}: got ${String(actual)}, want ${String(expected)}`);
}
const ansi = "\u001b[31mred\u001b[0m \u001b]0;terminal title\u0007ok";
eq(stripTerminalControlSequences(ansi), "red ok", "ANSI and OSC sequences are removed");
eq(stripTerminalControlSequences("a\u009b31mb"), "ab", "C1 CSI sequences are removed");
eq(stripTerminalControlSequences("a\u009d0;terminal title\u0007b"), "ab", "C1 OSC sequences terminated by BEL are removed");
eq(stripTerminalControlSequences("a\u0090private payload\u009cb"), "ab", "C1 string sequences terminated by ST are removed");
eq(stripTerminalControlSequences("a\u001bPprivate payload\u001b\\b"), "ab", "ESC string sequences terminated by ST are removed");
eq(stripTerminalControlSequences("a\u001b(Bb"), "ab", "multi-byte ESC character-set sequences are removed");
eq(stripTerminalControlSequences("a\u0000b\u0007c\tline\r\nnext"), "abc\tline\nnext", "unsafe controls are removed while layout whitespace remains");
const fenced = formatTerminalOutputForComposer("before ``` inside\nlast");
eq(fenced.startsWith("````console\n"), true, "the outer fence grows beyond embedded backticks");
eq(fenced.endsWith("\n````"), true, "the matching closing fence is emitted");
eq(fenced.includes("before ``` inside"), true, "terminal text remains intact inside the fenced block");
const clipped = formatTerminalOutputForComposer("0123456789", 4);
eq(clipped, "```console\n6789\n```", "large output keeps the newest bounded tail");
eq(formatTerminalOutputForComposer("\u001b[2J\u001b[H\u0000"), "", "output with no printable content is not inserted");
console.log("terminal-output: ok");
@@ -0,0 +1,231 @@
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const previousWindow = globalThis.window;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
type Session = {
id: string;
title: string;
shell: string;
cwd: string;
createdAt: number;
running: boolean;
};
type Workspace = { available: boolean; readOnly: boolean; sessions: Session[]; shells: { id: string; label: string }[] };
const pending = new Map<string, (value: Workspace) => void>();
const pendingReject = new Map<string, (error: Error) => void>();
const createPending: Array<(value: Session) => void> = [];
const createReject: Array<(error: Error) => void> = [];
let calls = 0;
let closeError: Error | null = null;
let writeError: Error | null = null;
const fakeApp = {
TerminalWorkspaceForTab(tabId: string) {
calls += 1;
return new Promise<Workspace>((resolve, reject) => {
pending.set(tabId, resolve);
pendingReject.set(tabId, reject);
});
},
CreateTerminalForTab() {
return new Promise<Session>((resolve, reject) => {
createPending.push(resolve);
createReject.push(reject);
});
},
async CloseTerminalForTab() {
if (closeError) throw closeError;
},
async WriteTerminalForTab() {
if (writeError) throw writeError;
},
};
(globalThis.window as unknown as { go: unknown }).go = { main: { App: fakeApp } };
const { resetTerminalStoreForTests, useTerminalStore } = await import("../store/terminal");
resetTerminalStoreForTests();
const oldRequest = useTerminalStore.getState().syncWorkspace("old-tab");
const newRequest = useTerminalStore.getState().syncWorkspace("new-tab");
pending.get("new-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
await newRequest;
pending.get("old-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
await oldRequest;
if (useTerminalStore.getState().tabId !== "new-tab") throw new Error("stale workspace response replaced the selected tab");
if (calls !== 2) throw new Error(`expected two workspace calls, got ${calls}`);
resetTerminalStoreForTests();
calls = 0;
const shared = useTerminalStore.getState().ensureReady("same-tab");
const sharedAgain = useTerminalStore.getState().ensureReady("same-tab");
pending.get("same-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
await Promise.all([shared, sharedAgain]);
if (calls !== 1) throw new Error(`ensureReady duplicated the first request: ${calls}`);
process.stdout.write("PASS stale responses and first-ready deduplication\n");
resetTerminalStoreForTests();
calls = 0;
createPending.length = 0;
const firstOpenCreate = useTerminalStore.getState().createSession("first-open");
await Promise.resolve();
const panelReady = useTerminalStore.getState().syncWorkspace("first-open");
await Promise.resolve();
if (calls !== 1) throw new Error(`panel readiness duplicated the first-open workspace request: ${calls}`);
pending.get("first-open")?.({
available: true,
readOnly: false,
sessions: [],
shells: [{ id: "default", label: "Default shell" }],
});
await panelReady;
await Promise.resolve();
if (createPending.length !== 1) throw new Error(`first-open create did not reach the backend: ${createPending.length}`);
const firstOpenSession: Session = {
id: "first-open-session",
title: "default",
shell: "default",
cwd: ".",
createdAt: 1,
running: true,
};
createPending[0]?.(firstOpenSession);
const createdOnFirstOpen = await firstOpenCreate;
if (createdOnFirstOpen?.id !== firstOpenSession.id) throw new Error("first-open create was discarded");
process.stdout.write("PASS first-open panel readiness shares the pending workspace request\n");
resetTerminalStoreForTests();
calls = 0;
const staleCapability = useTerminalStore.getState().syncWorkspace("capability-refresh");
await Promise.resolve();
const resolveStaleCapability = pending.get("capability-refresh");
const latestCapability = useTerminalStore.getState().syncWorkspace("capability-refresh", true);
await Promise.resolve();
if (calls !== 2) throw new Error(`forced capability refresh did not start a new request: ${calls}`);
pending.get("capability-refresh")?.({
available: true,
readOnly: false,
sessions: [],
shells: [{ id: "default", label: "Default shell" }],
});
await latestCapability;
resolveStaleCapability?.({
available: true,
readOnly: true,
sessions: [],
shells: [{ id: "default", label: "Default shell" }],
});
await staleCapability;
if (useTerminalStore.getState().workspace?.readOnly) {
throw new Error("stale read-only capability replaced the forced writable refresh");
}
process.stdout.write("PASS forced capability refresh supersedes an in-flight same-tab request\n");
resetTerminalStoreForTests();
createPending.length = 0;
const readyWorkspace: Workspace = {
available: true,
readOnly: false,
sessions: [],
shells: [{ id: "default", label: "Default shell" }],
};
useTerminalStore.setState({
tabId: "create-tab",
workspace: readyWorkspace,
loading: false,
activeSessionId: null,
});
const firstCreate = useTerminalStore.getState().createSession("create-tab");
const secondCreate = useTerminalStore.getState().createSession("create-tab");
await Promise.resolve();
await Promise.resolve();
if (createPending.length !== 2) throw new Error(`expected two create calls, got ${createPending.length}`);
const firstSession: Session = {
id: "first",
title: "first",
shell: "default",
cwd: ".",
createdAt: 1,
running: true,
};
const secondSession: Session = {
id: "second",
title: "second",
shell: "default",
cwd: ".",
createdAt: 2,
running: true,
};
createPending[1]?.(secondSession);
await secondCreate;
createPending[0]?.(firstSession);
await firstCreate;
const sessionIDs = useTerminalStore.getState().workspace?.sessions.map((session) => session.id).sort();
if (JSON.stringify(sessionIDs) !== JSON.stringify(["first", "second"])) {
throw new Error(`concurrent creates lost a session: ${JSON.stringify(sessionIDs)}`);
}
process.stdout.write("PASS concurrent terminal creates merge into the latest workspace state\n");
resetTerminalStoreForTests();
const failedSync = useTerminalStore.getState().syncWorkspace("sync-error");
pendingReject.get("sync-error")?.(new Error("workspace unavailable"));
await failedSync.catch(() => {});
if (useTerminalStore.getState().error !== "workspace unavailable") {
throw new Error(`workspace sync error was not exposed: ${useTerminalStore.getState().error}`);
}
if (useTerminalStore.getState().loading || useTerminalStore.getState().workspace !== null) {
throw new Error("failed workspace sync left a loading or partial workspace state");
}
process.stdout.write("PASS workspace sync failures remain visible and retryable\n");
resetTerminalStoreForTests();
createPending.length = 0;
createReject.length = 0;
useTerminalStore.setState({
tabId: "create-error",
workspace: readyWorkspace,
loading: false,
activeSessionId: null,
});
const failedCreate = useTerminalStore.getState().createSession("create-error");
await Promise.resolve();
createReject[0]?.(new Error("shell failed to start"));
await failedCreate.catch(() => {});
if (useTerminalStore.getState().error !== "shell failed to start") {
throw new Error(`terminal create error was not exposed: ${useTerminalStore.getState().error}`);
}
if (useTerminalStore.getState().workspace?.sessions.length !== 0) {
throw new Error("failed terminal create inserted a phantom session");
}
process.stdout.write("PASS terminal create failures do not insert phantom sessions\n");
resetTerminalStoreForTests();
closeError = new Error("terminal did not close");
useTerminalStore.setState({
tabId: "close-error",
workspace: { ...readyWorkspace, sessions: [firstSession] },
loading: false,
activeSessionId: firstSession.id,
});
await useTerminalStore.getState().closeSession("close-error", firstSession.id).catch(() => {});
if (useTerminalStore.getState().error !== "terminal did not close") {
throw new Error(`terminal close error was not exposed: ${useTerminalStore.getState().error}`);
}
if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) {
throw new Error("failed terminal close removed the live session from the workspace");
}
useTerminalStore.getState().clearError();
if (useTerminalStore.getState().error !== null) throw new Error("terminal error dismissal did not clear the error");
closeError = null;
process.stdout.write("PASS terminal close failures preserve the session and can be dismissed\n");
writeError = new Error("terminal input failed");
await useTerminalStore.getState().write("close-error", firstSession.id, "echo test").catch(() => {});
if (useTerminalStore.getState().error !== "terminal input failed") {
throw new Error(`terminal write error was not exposed: ${useTerminalStore.getState().error}`);
}
writeError = null;
process.stdout.write("PASS terminal write failures remain visible to the user\n");
if (previousWindow) globalThis.window = previousWindow;
else delete (globalThis as { window?: unknown }).window;
@@ -0,0 +1,160 @@
// Run: tsx src/__tests__/theme-editor-keyboard.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { ThemeGallery } from "../components/ThemeGallery";
import { LocaleProvider } from "../lib/i18n";
import type { ThemeExperienceView } from "../lib/themeExperience";
import type { ThemePackView } from "../lib/themePack";
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;
}
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 20));
}
const experience: ThemeExperienceView = {
themeMode: "dark",
baseStyle: "graphite",
effectiveStyle: "graphite",
activePack: null,
safeMode: false,
};
const savedPack: ThemePackView = {
id: "my-theme",
name: "My Theme",
baseStyle: "graphite",
builtin: false,
kind: "user",
active: false,
hasBackground: false,
tokens: {},
recipes: { density: "comfortable", corners: "soft" },
};
console.log("\ntheme editor keyboard ownership");
const dom = new JSDOM("<!doctype html><html><body><button id=\"opener\">Open editor</button><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.HTMLInputElement = dom.window.HTMLInputElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
let resolveSave: ((pack: ThemePackView) => void) | null = null;
const pendingSave = new Promise<ThemePackView>((resolve) => {
resolveSave = resolve;
});
window.go = {
main: {
App: {
ListThemePacks: async () => [],
SaveThemePack: async () => pendingSave,
},
},
};
const rootElement = document.getElementById("root");
const opener = document.getElementById("opener") as HTMLButtonElement | null;
if (!rootElement || !opener) throw new Error("missing test root");
const root = createRoot(rootElement);
function gallery(key: string) {
return (
<LocaleProvider>
<ThemeGallery
key={key}
experience={experience}
initialCreateBaseStyle="graphite"
onExperienceChange={() => {}}
onBack={() => {}}
/>
</LocaleProvider>
);
}
let outerEscapeCount = 0;
const onOuterEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") outerEscapeCount += 1;
};
document.addEventListener("keydown", onOuterEscape);
opener.focus();
await act(async () => {
root.render(gallery("normal"));
await flush();
});
const normalDialog = document.querySelector<HTMLElement>(".theme-gallery__editor");
if (!normalDialog) throw new Error("theme editor did not render");
const normalEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
await act(async () => {
normalDialog.dispatchEvent(normalEscape);
await flush();
});
ok(normalEscape.defaultPrevented, "normal Escape is consumed by the nested editor");
ok(outerEscapeCount === 0, "normal Escape does not reach the outer Settings handler");
ok(document.querySelector(".theme-gallery__editor") === null, "normal Escape closes only the theme editor");
ok(document.activeElement === opener, "normal Escape restores focus to the opener");
opener.focus();
await act(async () => {
root.render(gallery("busy"));
await flush();
});
const saveButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".theme-editor__actions button"))
.find((button) => button.textContent === "Save");
if (!saveButton) throw new Error("save button did not render");
await act(async () => {
saveButton.click();
await flush();
});
ok(saveButton.disabled, "save puts the editor into its busy state");
const busyDialog = document.querySelector<HTMLElement>(".theme-gallery__editor");
if (!busyDialog) throw new Error("busy theme editor did not remain mounted");
const busyEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
await act(async () => {
busyDialog.dispatchEvent(busyEscape);
await flush();
});
ok(busyEscape.defaultPrevented, "busy Escape is still consumed by the nested editor");
ok(outerEscapeCount === 0, "busy Escape does not close the outer Settings panel");
ok(document.querySelector(".theme-gallery__editor") !== null, "busy Escape keeps the editor mounted until save completes");
await act(async () => {
resolveSave?.(savedPack);
await pendingSave;
await flush();
});
document.removeEventListener("keydown", onOuterEscape);
await act(async () => root.unmount());
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -520,6 +520,12 @@ ok((bridgeSource.match(/paneOpacity:\s*0\.50/g) || []).length === 8, "browser mo
ok(viteSource.includes('resolve(configDir, "../themes/official")'), "Vite dev server permits only the official theme asset directory");
ok(stylesSource.includes("container: theme-gallery / inline-size"), "gallery establishes its own responsive container");
ok(stylesSource.includes("@container theme-gallery (max-width: 760px)"), "gallery collapses from its content width");
ok(gallerySource.includes('import { createPortal } from "react-dom"') && gallerySource.includes("document.body"), "theme editor escapes settings containing blocks through a body portal");
ok(gallerySource.includes('role="dialog"') && gallerySource.includes("aria-labelledby={titleId}"), "theme editor portal retains accessible dialog semantics");
ok(stylesSource.includes("container: theme-editor / inline-size"), "theme editor establishes an independent responsive container");
ok(stylesSource.includes("@container theme-editor (max-width: 920px)"), "theme editor collapses from its own width");
ok(stylesSource.includes(".theme-editor__setting-row .set-seg__btn { flex: 1; min-width: 0; }"), "all editor segmented setting buttons share available width");
ok(stylesSource.includes("grid-template-columns: repeat(3, minmax(0, 1fr))"), "base appearance options wrap at narrow editor widths");
ok(stylesSource.includes(".theme-gallery__preview-control"), "preview dimensions have labeled layout styling");
ok(gallerySource.includes("settings.themeGallery.scenePreviewHint") && gallerySource.includes("theme-gallery__preview-help"), "scene preview explains home and workspace behavior");
ok(localeZh.includes('"settings.themeGallery.sceneHome": "首页展示"') && localeZh.includes('"settings.themeGallery.sceneTask": "工作区展示"'), "scene options use explicit Chinese labels");
@@ -1,6 +1,9 @@
// Run: tsx src/__tests__/tool-subject.test.ts
import { subjectOf } from "../lib/tools";
import { toolGroupKind } from "../components/ToolGroup";
import { historyMessagesToItems, isReadOnlyTool } from "../lib/useController";
import type { HistoryMessage } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -19,6 +22,60 @@ console.log("\ntool subject contract");
eq(subjectOf("task", JSON.stringify({ description: "audit docs" })), "audit docs", "task subject uses description");
eq(subjectOf("run_skill", JSON.stringify({ name: "code-reviewer", arguments: "review this branch" })), "code-reviewer", "run_skill subject uses skill name");
eq(
subjectOf("use_capability", JSON.stringify({ action: "call", capability_id: "mcp-tool:github/search_issues" })),
"mcp-tool:github/search_issues",
"use_capability subject uses capability_id",
);
eq(subjectOf("use_capability", JSON.stringify({ action: "list" })), "list", "use_capability list falls back to action");
const capabilityArgs = JSON.stringify({ action: "call", capability_id: "mcp-tool:db/write" });
eq(
toolGroupKind({ kind: "tool", id: "reader", name: "use_capability", args: capabilityArgs, readOnly: true, status: "done" }),
"explore",
"resolved read-only MCP groups as research",
);
eq(
toolGroupKind({ kind: "tool", id: "writer", name: "use_capability", args: capabilityArgs, readOnly: false, status: "done" }),
"modify",
"resolved writer MCP stays in the modify group",
);
const history = historyMessagesToItems([{
role: "assistant",
content: "",
toolCalls: [
{
id: "old",
name: "use_capability",
arguments: capabilityArgs,
},
{
id: "reader",
name: "use_capability",
arguments: capabilityArgs,
resolvedName: "mcp__db__read",
capabilityId: "mcp-tool:db/read",
resolvedReadOnly: true,
},
{
id: "writer",
name: "use_capability",
arguments: capabilityArgs,
resolvedName: "mcp__db__write",
capabilityId: "mcp-tool:db/write",
resolvedReadOnly: false,
},
],
}] as HistoryMessage[], "h-").items.filter((item) => item.kind === "tool");
eq(history[0]?.kind === "tool" ? history[0].readOnly : true, false, "old proxy history fails closed as visible");
eq(history[1]?.kind === "tool" ? history[1].readOnly : false, true, "resolved reader history restores read-only classification");
eq(history[2]?.kind === "tool" ? history[2].readOnly : true, false, "resolved writer history restores writer classification");
eq(history[2]?.kind === "tool" ? history[2].resolvedName : "", "mcp__db__write", "history keeps resolved MCP target");
eq(isReadOnlyTool("read_file"), true, "legacy built-in reader remains read-only");
eq(isReadOnlyTool("grep"), true, "legacy search reader remains read-only");
eq(isReadOnlyTool("use_capability"), false, "legacy unresolved proxy remains fail-closed");
eq(isReadOnlyTool("unknown_tool"), false, "unknown history tool remains fail-closed");
if (failed) {
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
@@ -36,5 +36,115 @@ ok(
"opaque backdrop preserves the custom Creation scrollbar contract",
);
function ruleBody(selector: string): string {
const ruleStart = styles.indexOf(`${selector} {`);
if (ruleStart < 0) return "";
const bodyStart = styles.indexOf("{", ruleStart) + 1;
const bodyEnd = styles.indexOf("\n}", bodyStart);
return bodyEnd < 0 ? "" : styles.slice(bodyStart, bodyEnd);
}
const windowsPrimaryTranscriptSelector =
':root[data-platform="windows"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
const windowsThemeTranscriptSelector =
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
const windowsTaskTranscriptSelector =
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
const windowsTaskLeftTranscriptSelector =
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"][data-theme-safe-area="left"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
const windowsTaskRightTranscriptSelector =
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"][data-theme-safe-area="right"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
const windowsTranscriptRule = ruleBody(windowsPrimaryTranscriptSelector);
const windowsThemeTranscriptRule = ruleBody(windowsThemeTranscriptSelector);
const windowsThemeImageRule = ruleBody(`${windowsThemeTranscriptSelector}::before`);
const windowsThemeOverlayRule = ruleBody(`${windowsThemeTranscriptSelector}::after`);
const windowsThemeContentRule = ruleBody(`${windowsThemeTranscriptSelector} > *`);
const windowsTaskTranscriptRule = ruleBody(windowsTaskTranscriptSelector);
const windowsTaskLeftTranscriptRule = ruleBody(windowsTaskLeftTranscriptSelector);
const windowsTaskRightTranscriptRule = ruleBody(windowsTaskRightTranscriptSelector);
const lastTransparentTranscriptRule = styles.lastIndexOf(
'.app:not(.app--creation) .transcript-shell {\n background: transparent !important;',
);
const windowsTranscriptRuleIndex = styles.indexOf(`${windowsPrimaryTranscriptSelector} {`);
ok(
/position:\s*relative;/.test(windowsTranscriptRule) &&
/isolation:\s*isolate;/.test(windowsTranscriptRule) &&
/background-color:\s*Canvas\s*!important;/.test(windowsTranscriptRule) &&
/linear-gradient\(var\(--chat-bg,\s*var\(--bg\)\)/.test(windowsTranscriptRule) &&
/linear-gradient\(var\(--bg\),\s*var\(--bg\)\)\s*!important;/.test(windowsTranscriptRule) &&
/clip-path:\s*inset\(0\);/.test(windowsTranscriptRule),
"Windows Classic and Workbench primary transcripts paint a clipped opaque repaint backing (#7011)",
);
ok(
!styles.includes(':root[data-platform="windows"] .app .transcript {') &&
windowsPrimaryTranscriptSelector.includes(".chat-pane > .main > .transcript-shell > .transcript"),
"Windows repaint backing does not leak into HistoryPanel transcripts or Creation",
);
ok(
windowsTranscriptRuleIndex > lastTransparentTranscriptRule,
"Windows repaint backing wins over later theme-pack transparency",
);
ok(
/background-color:\s*Canvas\s*!important;/.test(windowsThemeTranscriptRule) &&
/background-image:\s*linear-gradient\(var\(--bg\),\s*var\(--bg\)\)\s*!important;/.test(windowsThemeTranscriptRule) &&
/position:\s*fixed;/.test(windowsThemeImageRule) &&
/inset:\s*-4%;/.test(windowsThemeImageRule) &&
/z-index:\s*var\(--z-transcript-scene-image\);/.test(windowsThemeImageRule) &&
/pointer-events:\s*none;/.test(windowsThemeImageRule) &&
/background-image:\s*var\(--windows-transcript-scene-image\);/.test(windowsThemeImageRule) &&
/background-size:\s*cover;/.test(windowsThemeImageRule) &&
/background-position:\s*var\(--windows-transcript-scene-focus-x\)\s+var\(--windows-transcript-scene-focus-y\);/.test(windowsThemeImageRule) &&
/opacity:\s*var\(--windows-transcript-image-opacity\);/.test(windowsThemeImageRule),
"Windows theme image preserves the fixed -4% overscan above an opaque transcript base",
);
ok(
/position:\s*fixed;/.test(windowsThemeOverlayRule) &&
/inset:\s*0;/.test(windowsThemeOverlayRule) &&
/z-index:\s*var\(--z-transcript-scene-overlay\);/.test(windowsThemeOverlayRule) &&
/pointer-events:\s*none;/.test(windowsThemeOverlayRule) &&
/var\(--windows-transcript-pane-alpha\)/.test(windowsThemeOverlayRule) &&
/var\(--windows-transcript-scene-overlay\)/.test(windowsThemeOverlayRule) &&
/background-size:\s*100%\s+100%,\s*100%\s+100%;/.test(windowsThemeOverlayRule),
"Windows theme pane and safe-area wash preserve the viewport-sized overlay geometry",
);
const transcriptImageZ = Number(styles.match(/--z-transcript-scene-image:\s*(-?\d+);/)?.[1]);
const transcriptOverlayZ = Number(styles.match(/--z-transcript-scene-overlay:\s*(-?\d+);/)?.[1]);
const transcriptContentZ = Number(styles.match(/--z-transcript-content:\s*(-?\d+);/)?.[1]);
ok(
transcriptImageZ >= 0 &&
transcriptOverlayZ > transcriptImageZ &&
transcriptContentZ > transcriptOverlayZ &&
/position:\s*relative;/.test(windowsThemeContentRule) &&
/z-index:\s*var\(--z-transcript-content\);/.test(windowsThemeContentRule),
"Windows theme layers paint above the opaque backing and below transcript content",
);
ok(
/--windows-transcript-scene-image:\s*var\(--theme-bg-home-image/.test(windowsThemeTranscriptRule) &&
/--windows-transcript-image-opacity:\s*var\(--theme-bg-home-opacity/.test(windowsThemeTranscriptRule) &&
/--windows-transcript-pane-alpha:\s*var\(--theme-pane-alpha/.test(windowsThemeTranscriptRule) &&
/--windows-transcript-scene-image:\s*var\(--theme-bg-task-image/.test(windowsTaskTranscriptRule) &&
/--windows-transcript-image-opacity:\s*var\(--theme-bg-task-opacity/.test(windowsTaskTranscriptRule) &&
/--windows-transcript-pane-alpha:\s*var\(--theme-pane-task-alpha/.test(windowsTaskTranscriptRule),
"Windows home and task scenes use their matching image, opacity, and pane tokens",
);
ok(
/radial-gradient\(/.test(windowsTaskTranscriptRule) &&
/linear-gradient\(\s*90deg,/.test(windowsTaskLeftTranscriptRule) &&
/linear-gradient\(\s*270deg,/.test(windowsTaskRightTranscriptRule) &&
/--windows-transcript-scene-overlay:/.test(windowsTaskTranscriptRule) &&
/--windows-transcript-scene-overlay:/.test(windowsTaskLeftTranscriptRule) &&
/--windows-transcript-scene-overlay:/.test(windowsTaskRightTranscriptRule),
"Windows task scene preserves center, left, and right safe-area washes",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -244,6 +244,13 @@ eq(
);
eq(finalDeclaration(".code", "font-family"), "var(--font-code-family)", "code blocks keep the regional code font");
eq(finalDeclaration(".md-code", "font-family"), "var(--font-code-family)", "inline code keeps the regional code font");
eq(finalDeclaration(".code code", "font-family"), "inherit", "nested code text inherits the regional code font");
eq(finalDeclaration(".code-line-text", "font-family"), "inherit", "line-numbered code text inherits its viewer font");
eq(
finalDeclaration(".code-lines-wrap", "font-family"),
"var(--typography-code-font, var(--font-mono))",
"line-numbered code viewers use the regional code font",
);
eq(
finalDeclaration(".diff", "font-size"),
"var(--typography-code-size, calc(12.5px * var(--global-font-scale)))",
Loaded 100 of 699 files, more files were not shown because too many files have changed in this diff. Show more