Merge branch 'main' into fix/sessionid-migration-gaps

Conflict resolutions:
- serve transcript routing: keep the branch's identity routing
  (resolveReadControllerLocked, session-id mirror 501) and adopt main's
  lock discipline (release bindMu across reads, re-verify after) plus the
  new /transcript/follow endpoint; the post-read check re-resolves the
  reference so detached/identity reads verify correctly.
- remote transcript API: keep main's /transcript/follow stream client and
  re-apply the branch's per-route capability requirements.
- runtime-state sampling: keep main's extracted sampleRemoteRuntimeSessions
  (projection mutex no longer held across controller reads) with the
  branch's background-freshness rule inside it.
- useRemoteSession: take main's follower-based hydration as the base and
  re-apply the branch's static telemetry import, ready-publication pending
  reset, and spectator status reconcile loop.
- SessionRef frontend plumbing, catalog preview helpers, and the bundle
  budget baseline follow main; remote tabs keep the flat sessionId until
  the identity migration.
This commit is contained in:
XTLine committed 2026-09-16 13:57:30 +08:00
commit 6885a9d88c
497 files changed
+29025 -4753

No files matched your search

+112 -18
View File
@@ -101,7 +101,8 @@ jobs:
with:
module: root
- if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
# Both non-Linux legs drive their package selection through a Node script.
- if: env.RUN_STEPS == 'true' && runner.os != 'Linux'
uses: actions/setup-node@v7
with:
node-version: "22"
@@ -162,7 +163,9 @@ jobs:
run: go build ./...
- name: test
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
# Windows full coverage belongs to the disjoint selectors below and
# the isolated jobs; running ./... here would execute every test twice.
if: env.RUN_STEPS == 'true' && (runner.os == 'Linux' || (runner.os == 'macOS' && github.event_name != 'pull_request'))
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -170,6 +173,18 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
# Public repositories get five concurrent macOS runners and one pull
# request already claims three. Linux proves the portable packages on
# every pull request, so this leg sweeps only the darwin-specific ones
# and pushes keep the full ./... run above — the same tiering the Windows
# legs and the race job already use.
- name: test (macOS platform packages)
if: env.RUN_STEPS == 'true' && runner.os == 'macOS' && github.event_name == 'pull_request'
timeout-minutes: 15
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: node scripts/macos-go-tests.mjs darwin
# Agent, boot and control own isolated Windows runners on PRs and pushes.
# The shared selector excludes them here, preserving platform smoke
# coverage without competing durable-session I/O or duplicate execution.
@@ -459,8 +474,14 @@ jobs:
if: github.event_name != 'pull_request' && env.RUN_STEPS == 'true'
run: go test -race ./...
# Required check. Every desktop job reaches the ruleset through this one name,
# so a job that is not listed here is not gated at all: that is how a failing
# desktop-windows-go merged. PACKAGE_REQUIRED mirrors desktop-windows-package's
# own `if:` verbatim rather than testing for `push`, so workflow_dispatch does
# not expect a skip from a job that runs.
desktop:
needs: [changes, desktop-prepare, desktop-go, desktop-go-race, desktop-frontend, desktop-browser]
needs: [changes, desktop-prepare, desktop-go, desktop-go-race, desktop-frontend, desktop-browser,
desktop-macos, desktop-windows, desktop-windows-go, desktop-windows-package]
if: always()
runs-on: ubuntu-latest
steps:
@@ -470,11 +491,16 @@ jobs:
PREPARE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.desktop != 'false' }}
NATIVE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false' }}
FRONTEND_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.electron != 'false' }}
PACKAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
GO_RESULT: ${{ needs.desktop-go.result }}
GO_RACE_RESULT: ${{ needs.desktop-go-race.result }}
FRONTEND_RESULT: ${{ needs.desktop-frontend.result }}
BROWSER_RESULT: ${{ needs.desktop-browser.result }}
MACOS_RESULT: ${{ needs.desktop-macos.result }}
WINDOWS_RESULT: ${{ needs.desktop-windows.result }}
WINDOWS_GO_RESULT: ${{ needs.desktop-windows-go.result }}
PACKAGE_RESULT: ${{ needs.desktop-windows-package.result }}
run: |
test "$CHANGES_RESULT" = success
expected() { if [ "$1" = true ]; then echo success; else echo skipped; fi; }
@@ -484,6 +510,12 @@ jobs:
test "$FRONTEND_RESULT" = "$(expected "$FRONTEND_REQUIRED")"
# The browser aggregate always runs and validates skipped groups itself.
test "$BROWSER_RESULT" = success
# native ⇒ desktop by construction, so these can never contradict
# PREPARE_REQUIRED above.
test "$MACOS_RESULT" = "$(expected "$NATIVE_REQUIRED")"
test "$WINDOWS_RESULT" = "$(expected "$NATIVE_REQUIRED")"
test "$WINDOWS_GO_RESULT" = success
test "$PACKAGE_RESULT" = "$(expected "$PACKAGE_REQUIRED")"
desktop-frontend:
needs: [changes, desktop-prepare]
@@ -1123,13 +1155,17 @@ jobs:
# single test runs (GOCACHE restored, Defender excluded). Running it beside
# the Electron steps makes the leg's wall time the longer half rather than
# the sum, and a Go flake reruns only this job.
desktop-windows-go:
desktop-windows-go-group:
needs: [changes, desktop-prepare]
if: always() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false')
if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
runs-on: windows-latest
# Measured 17.1-17.3 minutes on three consecutive main-v2 runs; bound
# hangs, not duration.
# Independent runners prevent valid groups from consuming each other's
# step deadline. Go retains its default per-package ten-minute timeout.
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
group: [A-D, E-H, I-P, Q-Z]
defaults:
run:
shell: bash
@@ -1182,36 +1218,56 @@ jobs:
--source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
--pnpm-version "$(pnpm --version)"
- name: Validate Windows test partition
run: node --test ../scripts/desktop-windows-go-tests.test.mjs
- name: test (Windows desktop and update helper)
# Keep native Go output here. JSON mode forces verbose output into the
# test cache and spent 8-10 minutes finalizing it on Windows runners.
timeout-minutes: 25
# Keep each package below Go's default 10-minute timeout without
# relaxing it. These disjoint filters cover every test exactly once;
# examples, fuzz seeds, and other test names remain in the first group.
run: |
go test -skip '^Test[I-Z]' ./...
go test -run '^Test[I-P]' ./...
go test -run '^Test[Q-Z]' -skip '^TestWindowsTerminalProcessConPTYSmoke$' ./...
# The driver checks the current native inventory for unique ownership.
run: node ../scripts/desktop-windows-go-tests.mjs ${{ matrix.group }}
# ConPTY startup and teardown depend on the hosted runner's interactive
# Windows environment. Keep the end-to-end probe visible without making
# a single host scheduling stall equivalent to a Go correctness failure.
- name: probe (Windows ConPTY host integration)
if: matrix.group == 'Q-Z'
id: conpty-smoke
continue-on-error: true
timeout-minutes: 2
run: go test -run '^TestWindowsTerminalProcessConPTYSmoke$' .
- name: report ConPTY probe failure
if: always() && steps.conpty-smoke.outcome == 'failure'
if: always() && matrix.group == 'Q-Z' && steps.conpty-smoke.outcome == 'failure'
shell: bash
run: echo "::warning::The hosted Windows ConPTY integration probe failed; deterministic Windows Go tests still passed."
- name: test (vendored systray identity)
if: matrix.group == 'Q-Z'
timeout-minutes: 2
run: go test -timeout=60s fyne.io/systray
desktop-windows-go:
needs: [changes, desktop-prepare, desktop-windows-go-group]
if: always()
runs-on: ubuntu-latest
steps:
- name: Verify Windows desktop Go groups
env:
CHANGES_RESULT: ${{ needs.changes.result }}
SHOULD_RUN: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false' }}
PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
GROUP_RESULT: ${{ needs.desktop-windows-go-group.result }}
run: |
test "$CHANGES_RESULT" = success
if [ "$SHOULD_RUN" = true ]; then
test "$PREPARE_RESULT" = success
test "$GROUP_RESULT" = success
else
test "$GROUP_RESULT" = skipped
fi
# Installer packaging is packaging validation, not a code gate, and it shares
# no state with the test steps above. It runs beside them instead of after
# them so the Windows leg's wall time is the longer half rather than the sum,
@@ -1434,11 +1490,43 @@ jobs:
.github/workflows/release-verify-issues.yml \
.github/workflows/docs-impact.yml \
.github/workflows/ci.yml
node --test scripts/desktop-release-artifacts.test.mjs scripts/signpath-checkpoint.test.mjs scripts/ci-workflow.test.mjs scripts/notarize-desktop.test.mjs scripts/verify-windows-gui-subsystem.test.mjs scripts/windows-go-tests.test.mjs
node --test scripts/desktop-windows-go-tests.test.mjs
node --test scripts/desktop-release-artifacts.test.mjs scripts/signpath-checkpoint.test.mjs scripts/ci-workflow.test.mjs scripts/notarize-desktop.test.mjs scripts/verify-windows-gui-subsystem.test.mjs scripts/windows-go-tests.test.mjs scripts/macos-go-tests.test.mjs
bash scripts/release-workflows.test.sh
node scripts/check-single-release-public-contract.mjs
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
# Required check for the root-module and site jobs the per-OS `test` legs do
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
# always report a result: `success` here means "did not fail", not "validated
# this diff" — the same trade-off the required `test` legs already make. Do
# not add govulncheck: it sets continue-on-error, so needs.*.result is
# `success` even when its steps fail and the assertion would be a tautology.
root:
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
if: always()
runs-on: ubuntu-latest
steps:
- name: Verify root validation jobs
env:
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
CONTROL_RESULT: ${{ needs.windows-control.result }}
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
SDK_RESULT: ${{ needs.sdk.result }}
SITE_RESULT: ${{ needs.site.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
test "$CHANGES_RESULT" = success
expected() { if [ "$1" = true ]; then echo success; else echo skipped; fi; }
test "$CONTROL_RESULT" = success
test "$SDK_RESULT" = success
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
# frontend unit coverage can run once in desktop-frontend without weakening
# the branch gate.
lint:
@@ -1470,9 +1558,13 @@ jobs:
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
# The notes_only term matches every other gated job here. Without it a
# release-notes-only push runs this job while the root aggregate expects the
# house-style skip, which would leave main-v2 permanently red. A pure
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
site:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.site != 'false')
if: always() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false')
runs-on: ubuntu-latest
defaults:
run:
@@ -1511,9 +1603,11 @@ jobs:
- name: govulncheck
run: govulncheck ./...
# `always()` matches every other gated job: a failed changes job makes this
# run the full sweep instead of silently reporting skipped to the aggregate.
coverage:
needs: changes
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
if: always() && (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+17 -8
View File
@@ -95,12 +95,12 @@ on:
default: ""
type: string
desktop_manual_only:
description: "One-time approved v1.38.8 manual Desktop recovery"
description: "Approved manual Desktop distribution: unsigned Windows, no update pointer move"
required: false
default: false
type: boolean
reuse_manual_artifacts:
description: "Reuse the exact verified v1.38.8 manual producer run"
description: "Reuse the exact verified v1.38.8 manual producer run (v1.38.8 only)"
required: false
default: false
type: boolean
@@ -241,8 +241,13 @@ jobs:
exit 1
fi
if [ "${{ inputs.desktop_manual_only }}" = "true" ]; then
if [ "${{ inputs.orchestrated }}" != "true" ] || [ "${{ inputs.channel }}" != "stable" ] || [ "${{ needs.resolve.outputs.tag }}" != "desktop-v1.38.8" ] || [ "${{ needs.resolve.outputs.sha }}" != "7278072720a2dc7a31cce0eec18c1eacc149c0e0" ]; then
echo "::error::manual Desktop exception is restricted to the approved immutable v1.38.8 candidate"
if [ "${{ inputs.orchestrated }}" != "true" ] || [ "${{ inputs.channel }}" != "stable" ]; then
echo "::error::manual Desktop exception requires the approved stable orchestrator"
exit 1
fi
if ! bash scripts/manual-desktop-exception.sh validate \
"${{ needs.resolve.outputs.tag }}" "${{ needs.resolve.outputs.sha }}"; then
echo "::error::manual Desktop exception is restricted to approved candidates"
exit 1
fi
fi
@@ -431,7 +436,7 @@ jobs:
rm -f "$RUNNER_TEMP/cert.p12"
# Official automatic-update releases require Authenticode. The guarded
# v1.38.8 manual-only recovery retains minisign without advancing updates.
# manual-only exception retains minisign without advancing updates.
- name: Require Windows Authenticode signing
if: runner.os == 'Windows' && github.repository == 'esengine/DeepSeek-Reasonix' && !inputs.desktop_manual_only
run: |
@@ -978,16 +983,20 @@ jobs:
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
# Name the release being published instead of a fixed version, so the
# disclosure cannot outlive or misdescribe the exception it belongs to.
- name: Disclose manual Desktop distribution
if: ${{ inputs.desktop_manual_only }}
env:
MANUAL_VERSION: ${{ needs.resolve.outputs.version }}
run: |
cat >> /tmp/release-notes.md <<'NOTES'
cat >> /tmp/release-notes.md <<NOTES
## Manual desktop downloads / 桌面版手动下载
This v1.38.8 desktop release is manual-download only on every platform. Windows packages do not carry Reasonix Authenticode signatures because SignPath signing is unavailable; Windows may show an unknown-publisher warning. Detached minisign signatures and SHA-256 checksums remain available. Desktop automatic-update channels remain on v1.38.7. CLI and npm distribution are unaffected.
This v${MANUAL_VERSION} desktop release is manual-download only on every platform. Windows packages do not carry Reasonix Authenticode signatures because SignPath signing is unavailable; Windows may show an unknown-publisher warning. Detached minisign signatures and SHA-256 checksums remain available. Desktop automatic-update channels are unchanged by this release and keep serving their previous version. CLI and npm distribution are unaffected.
本次 v1.38.8 桌面版所有平台均需手动下载安装。因 SignPath 签名服务暂不可用,Windows 包不含 Reasonix Authenticode 签名,系统可能显示未知发布者提示;仍提供 minisign 签名和 SHA-256 校验。桌面自动更新通道保留在 v1.38.7,CLI 和 npm 正常发布。
本次 v${MANUAL_VERSION} 桌面版所有平台均需手动下载安装。因 SignPath 签名服务暂不可用,Windows 包不含 Reasonix Authenticode 签名,系统可能显示未知发布者提示;仍提供 minisign 签名和 SHA-256 校验。本次发布不改变桌面自动更新通道,其仍指向此前版本;CLI 和 npm 正常发布。
NOTES
- name: Publish GitHub release
+10 -6
View File
@@ -39,12 +39,12 @@ on:
default: true
type: boolean
desktop_manual_only:
description: "One-time v1.38.8 recovery: unsigned Windows, manual Desktop downloads only"
description: "Approved exception: unsigned Windows, manual Desktop downloads only"
required: false
default: false
type: boolean
reuse_manual_artifacts:
description: "Recover the verified v1.38.8 artifacts from run 34816299501"
description: "Recover the verified v1.38.8 artifacts from run 34816299501 (v1.38.8 only)"
required: false
default: false
type: boolean
@@ -92,12 +92,16 @@ jobs:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- name: Restrict manual Desktop recovery
# scripts/manual-desktop-exception.sh owns which tags may ship without
# Windows Authenticode. A tag approved before its candidate exists keeps
# the normal candidate and push-CI validation below.
- name: Restrict manual Desktop distribution
if: ${{ inputs.desktop_manual_only }}
run: |
test "${{ inputs.allow_recovery }}" = "true"
test "${{ steps.release.outputs.cli_tag }}" = "v1.38.8"
test "${{ steps.release.outputs.sha }}" = "7278072720a2dc7a31cce0eec18c1eacc149c0e0"
bash scripts/manual-desktop-exception.sh validate \
"${{ steps.release.outputs.desktop_tag }}" \
"${{ steps.release.outputs.sha }}" \
"${{ inputs.allow_recovery }}"
- name: Revalidate normal release candidate and exact push CI
if: ${{ !inputs.allow_recovery }}
env:
+12
View File
@@ -28,6 +28,18 @@ branch.
### Changed
- **Persistent bash PTY:** ordinary foreground `bash` calls in a session now
share one PTY, so `cd`, exported variables, and shell functions survive
across calls. Output stays byte-identical to one-shot execution, stdin stays
detached, and a timeout or cancel reports partial output and says the shell
was reset. Background jobs, commands that background a child, per-call
write-root escalations, host terminals, and PowerShell hosts stay one-shot.
The bash tool schema and description are unchanged.
- **CLI YOLO shortcuts:** the CLI displays the unrestricted preset as YOLO;
`Ctrl+Y` toggles the canonical `danger-full-access` permission, while
`Shift+Tab` cycles Read only → Workspace write → YOLO → Plan.
- **Harness-style scheduling and recovery:** calls take effect in execution
order, including same-batch read/edit sequences. Bounded reads create no
completion debt. Unknown external effects are durable advisory facts and no
+16 -58
View File
@@ -192,11 +192,11 @@ type App struct {
// App.mu guards both maps and every desktopSessionRuntime field.
runtimeByID map[string]*desktopSessionRuntime
runtimeBySessionKey map[string]*desktopSessionRuntime
// sessionServices owns one final-format runtime registry per physical v3
// root. Controllers for tabs in the same scope attach to this registry
// instead of constructing competing Service instances over the same files.
// sessionServices contains one SessionID-only registry for the whole local
// Desktop host. desktopSessions owns its persistence and navigation state.
sessionServicesMu sync.Mutex
sessionServices map[string]*session.Service
desktopSessions desktopSessionState
// tabsRestored is closed when restoreOrBuildTabs has finished populating
// a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
@@ -219,7 +219,6 @@ type App struct {
// one-conversation layout so overlapping navigation cannot remove the tab
// another navigation is still activating.
singleSurfaceMu sync.Mutex
// worktreeMergeMu serializes the inspect-confirm-merge/finalize mutation
// boundary. Git identities are still revalidated after workspace leases are
// acquired; this mutex only prevents duplicate in-process Wails calls.
@@ -460,6 +459,7 @@ func NewApp() *App {
runtimeByID: map[string]*desktopSessionRuntime{},
runtimeBySessionKey: map[string]*desktopSessionRuntime{},
sessionServices: map[string]*session.Service{},
desktopSessions: newDesktopSessionState(),
catalogReconcileJobs: map[string]*desktopCatalogReconcileJob{},
detachedSessions: map[string]*WorkspaceTab{},
mediaTokens: newMediaTokenStore(),
@@ -500,6 +500,7 @@ func (a *App) Platform() string {
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.shuttingDown.Store(false)
a.initializeDesktopSessionRoot()
// Only the process that claimed the pre-shell diagnostics lock consumes
// lifecycle evidence.
initializeLifecycleDiagnostics(a)
@@ -508,6 +509,7 @@ func (a *App) startup(ctx context.Context) {
a.startNativeShellSupport()
a.enableDeferredRebuildRetry()
a.startHistoryIndexMigration()
a.startDesktopSessionMigration(ctx)
if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
@@ -752,6 +754,7 @@ func (a *App) restoreOrBuildTabs() {
tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id)
}
tab.model = entry.Model
tab.SessionWorkspace.ID = restoredWorkspaceID(entry)
tab.effort = cloneStringPtr(entry.Effort)
// Legacy role fields remain readable, but the retired setting no
// longer changes restored-session behavior.
@@ -858,6 +861,7 @@ func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *Wo
ID: id,
Scope: scope,
WorkspaceRoot: workspaceRoot,
SessionWorkspace: desktopTabWorkspace{ID: desktopWorkspaceID(scope, workspaceRoot)},
TopicID: topicID,
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
@@ -6480,59 +6484,6 @@ func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
return out
}
// Meta describes the session for the frontend's header and status line.
type Meta struct {
Label string `json:"label"`
Ready bool `json:"ready"`
Runtime SessionRuntimeView `json:"runtime"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
SessionPath string `json:"sessionPath,omitempty"`
SessionID string `json:"sessionId,omitempty"`
SessionRevision int64 `json:"sessionRevision,omitempty"`
SessionDigest string `json:"sessionDigest,omitempty"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
VisionFallbackEnabled bool `json:"visionFallbackEnabled,omitempty"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
// TokenMode and AgentPreset are deprecated dual-write wire values pinned to
// their safe defaults; one-version-old frontends still parse them.
TokenMode string `json:"tokenMode"`
AgentPreset string `json:"agentPreset,omitempty"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
GoalView *goaldomain.View `json:"goalView,omitempty"`
GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"`
// Nil means no authoritative snapshot; non-nil empty means clear the panel.
CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"`
// PinnedFiles holds metadata about standing pinned context files for this tab.
PinnedFiles []PinnedFileInfo `json:"pinnedFiles,omitempty"`
// Remote marks a remote session tab; its readiness is carried by the
// remote-tab state channel rather than a local controller.
Remote *RemoteTabRef `json:"remote,omitempty"`
}
type GoalRuntimeView struct {
TurnsUsed int `json:"turnsUsed"`
TurnsLimit int `json:"turnsLimit"` // Deprecated: always 0.
TokensUsed int `json:"tokensUsed"`
RequestsUsed int `json:"requestsUsed,omitempty"`
WorkDurationMs int64 `json:"workDurationMs,omitempty"`
TokensLimit int `json:"tokensLimit"` // Deprecated: always 0; retained for bridge compatibility.
NoProgressTurns int `json:"noProgressTurns"`
NoProgressLimit int `json:"noProgressLimit"` // Deprecated: always 0.
LastReason string `json:"lastReason,omitempty"`
StopCause string `json:"stopCause,omitempty"`
BudgetExtensions int `json:"budgetExtensions"` // Deprecated: always 0.
}
func goalRuntimeViewFromController(ctrl control.SessionAPI) *GoalRuntimeView {
if ctrl == nil {
return nil
@@ -6615,6 +6566,12 @@ func (a *App) MetaForTab(tabID string) Meta {
goalView = reader.RuntimeStateSnapshot().Goal
}
sessionPath := strings.TrimSpace(snap.sessionPath)
sessionID := strings.TrimSpace(snap.sessionID)
var sessionRef *session.SessionRef
if sessionID != "" {
ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}
sessionRef = &ref
}
var sessionRevision int64
var sessionDigest string
if branchMeta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
@@ -6628,7 +6585,8 @@ func (a *App) MetaForTab(tabID string) Meta {
StartupErr: snap.startupErr,
EventChannel: eventChannel,
SessionPath: sessionPath,
SessionID: strings.TrimSpace(snap.sessionID),
SessionID: sessionID,
Session: sessionRef,
SessionRevision: sessionRevision,
SessionDigest: sessionDigest,
Cwd: cwd,
+9 -3
View File
@@ -146,7 +146,10 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
ctrl := tab.Ctrl
// Retry ownership does not depend on how quickly the filesystem rejects
// a write. Inject that failure, then use the real controller for recovery.
tab.Ctrl = &snapshotErrorSessionController{SessionAPI: ctrl, err: os.ErrPermission}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
@@ -154,8 +157,8 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
if failures != maxTabSnapshotFailureRetries+1 {
t.Fatalf("autosave failures = %d, want %d attempts", failures, maxTabSnapshotFailureRetries+1)
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
@@ -164,6 +167,9 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
a.mu.Lock()
tab.Ctrl = ctrl
a.mu.Unlock()
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"context"
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/event"
)
func TestDetachedAskPublishesGlobalPendingInteraction(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := writeTopicSessionWithPrompt(t, dir, "a.jsonl", "topic-a", "Conversation A", "", "fixture", time.Now())
a := NewApp()
a.ctx = context.Background()
pending := make(chan RuntimeSessionState, 1)
a.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
if name != "runtime-state:changed" {
return
}
for _, session := range payload[0].(RuntimeStateProjection).Sessions {
if !session.Open && len(session.State.Interactions) > 0 {
select {
case pending <- session:
default:
}
}
}
}
sink := &tabEventSink{tabID: "A", app: a, ctx: a.ctx}
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Sink: sink})
defer ctrl.Close()
tab := &WorkspaceTab{ID: "A", Scope: "global", TopicID: "topic-a", TopicTitle: "Conversation A", SessionPath: path, Ctrl: ctrl, sink: sink, Ready: true}
a.tabs[tab.ID] = tab
a.activeTabID = tab.ID
ctrl.Submit("fixture turn")
<-runner.started
if !a.detachRuntimeForReplacement(tab) {
t.Fatal("could not detach running A")
}
a.mu.Lock()
delete(a.tabs, "A")
a.tabs["B"] = &WorkspaceTab{ID: "B"}
a.activeTabID = "B"
a.mu.Unlock()
if sink.context() != nil {
t.Fatal("detached transcript must stay disconnected")
}
ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
go func() {
defer close(done)
_, _ = ctrl.Ask(ctx, []event.AskQuestion{{ID: "choice", Prompt: "Choose"}})
}()
defer func() {
cancel()
<-done
close(runner.release)
waitNotRunning(t, ctrl)
a.mu.RLock()
detached := a.detachedSessions[sessionRuntimeKey(path)]
a.mu.RUnlock()
a.quiesceTabAutosave(detached)
}()
select {
case session := <-pending:
prompt := session.State.Interactions[0]
if session.TabID == "B" || session.SessionPath != path || prompt.Kind != "ask" || prompt.RequestID == "" || prompt.TurnID == "" {
t.Fatalf("wrong background prompt identity: %+v", session)
}
case <-time.After(3 * time.Second):
t.Fatal("background Ask was not pushed without reopening A")
}
a.mu.RLock()
active := a.activeTabID
a.mu.RUnlock()
if active != "B" {
t.Fatalf("background Ask changed active tab to %q", active)
}
}
+3
View File
@@ -118,6 +118,9 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
locator := forkedSessionLocator{SessionPath: newPath}
if exclusiveV3 {
locator = forkedSessionLocator{SessionID: newPath}
if err := a.attachForkedDesktopSession(a.bootContext(), sourceTab, newPath); err != nil {
return ForkWorktreeResultView{}, a.rollbackUnusedForkWorktree(created, fmt.Errorf("publish fork workspace membership: %w", err))
}
}
opened, err := a.openForkedSessionTabWithWorkspace(sourceTab, locator, created.WorkspaceRoot)
result.Tab = opened.tab
+3
View File
@@ -171,6 +171,9 @@ func (a *App) CreateForkForTab(tabID string, anchor ForkAnchorView) (ForkCreatio
if view.Opened {
return view, nil
}
if err := a.attachForkedDesktopSession(a.bootContext(), tab, childID); err != nil {
return ForkCreationView{}, fmt.Errorf("publish fork workspace membership: %w", err)
}
opened, openErr := a.openForkedSessionTabWithWorkspace(tab, forkedSessionLocator{SessionID: childID}, "")
view.TabID = opened.tab.ID
if openErr == nil && opened.tab.ID != "" {
+21
View File
@@ -25,6 +25,8 @@ type forkTargetsStubController struct {
createName string
createOp string
creates int
service *session.Service
cwd string
}
// RuntimeStatus reports a turn in flight when running is set, so a scenario can
@@ -43,9 +45,25 @@ func (c *forkTargetsStubController) ForkTargets() (session.ForkTargetSet, error)
func (c *forkTargetsStubController) CreateForkSession(request session.ForkRequest, name string) (string, error) {
c.creates++
c.createTurn, c.createBoundary, c.createName, c.createOp = request.TurnID, request.BoundarySequence, name, request.OperationID
if c.createErr == nil && c.service != nil {
var runtime *session.Runtime
runtime, c.createErr = c.service.Create(context.Background(), session.CreateOptions{
SessionID: c.childID, CWD: c.cwd, ParentSessionID: request.Source.SessionID, Origin: session.SessionOriginFork,
})
if c.createErr == nil {
c.createErr = c.service.Close(context.Background(), runtime.Ref())
}
}
return c.childID, c.createErr
}
func enableForkTargetPersistence(t *testing.T, app *App, ctrl *forkTargetsStubController) {
t.Helper()
pinDesktopSessionRoot(t, app)
ctrl.service = app.desktopSessionService("")
ctrl.cwd = globalWorkspaceRoot()
}
func (c *forkTargetsStubController) UsesExclusiveSession() bool { return true }
func (c *forkTargetsStubController) SessionRef() (session.SessionRef, bool) {
return session.SessionRef{HostID: "host-1", SessionID: "source-1"}, true
@@ -206,6 +224,7 @@ func TestCreateForkForTabOpensChildInNewTab(t *testing.T) {
}
app := NewApp()
app.setTestCtrl(ctrl, "")
enableForkTargetPersistence(t, app, ctrl)
app.tabs["test"].SessionID = "source-1"
app.tabs["test"].Scope = "global"
app.tabs["test"].TopicTitle = "Source topic"
@@ -255,6 +274,7 @@ func TestCreateForkForTabKeepsChildWhenTabAttachFails(t *testing.T) {
}
app := NewApp()
app.setTestCtrl(ctrl, "")
enableForkTargetPersistence(t, app, ctrl)
app.tabs["test"].SessionID = "source-1"
app.tabs["test"].Scope = "global"
app.tabs["test"].TopicTitle = "Source topic"
@@ -399,6 +419,7 @@ func TestCreateForkForTabReopensCompletedOperationAfterAttachFailure(t *testing.
ctrl := &forkTargetsStubController{tabScopedActionController: newTabScopedActionController(), childID: "recovered-child"}
app := NewApp()
app.setTestCtrl(ctrl, "")
enableForkTargetPersistence(t, app, ctrl)
app.tabs["test"].Scope = "global"
app.tabs["test"].SessionID = "source-1"
anchor := ForkAnchorView{SourceHostID: "host-1", SourceSessionID: "source-1", TurnID: "turn", BoundarySequence: 9}
+8 -21
View File
@@ -3,7 +3,7 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { startPreviewServer } from "./vite-preview-server.mjs";
import { chooseAppLayout } from "./app-page-actions.mjs";
import { newSessionButton, selectSession } from "./app-page-actions.mjs";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
@@ -28,27 +28,21 @@ async function settle(page, frames = 5) {
}), frames);
}
async function chooseLayout(page, label, className) {
await chooseAppLayout(page, label, className);
await settle(page);
}
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(`http://127.0.0.1:${port}/?mock=bench&bench=1&app-lifecycle-probe=1`, { waitUntil: "domcontentloaded" });
await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
await page.locator(".project-tree").waitFor();
await page.locator(".project-tree").first().waitFor();
await page.evaluate(() => {
window.__appBrowserIdentity = {
composer: document.querySelector("textarea.composer__input:not([aria-hidden=true])"),
projectTree: document.querySelector(".project-tree"),
sidebar: document.querySelector(".sidebar"),
};
});
const composer = page.locator("textarea.composer__input:not([aria-hidden=true])");
await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click();
await selectSession(page, "bench:small-6t");
await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('ASYNC LAYOUT EXPANSION COMPLETE'));
await composer.fill("layout-owned draft");
// Raw Markdown fallbacks become parsed DOM asynchronously. History preservation
@@ -94,20 +88,15 @@ try {
});
assert(await page.locator(".app.app--workbench").count() === 1, "workbench layout renders from the authoritative startup snapshot");
await chooseLayout(page, "Creation", "app--creation");
assert(await composer.inputValue() === "layout-owned draft", "creation layout preserves the Composer draft and mount");
await chooseLayout(page, "Workbench", "app--workbench");
assert(await composer.inputValue() === "layout-owned draft", "workbench layout preserves the Composer draft and mount");
const identities = await page.evaluate(() => ({
composer: window.__appBrowserIdentity.composer === document.querySelector("textarea.composer__input:not([aria-hidden=true])"),
projectTree: window.__appBrowserIdentity.projectTree === document.querySelector(".project-tree"),
sidebar: window.__appBrowserIdentity.sidebar === document.querySelector(".sidebar"),
workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'),
workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'),
preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'),
}));
assert(Object.values(identities).every(Boolean), "layout variants and management-page visits retain Sidebar, Composer, actual WorkspacePanel/tree and file preview identity");
assert(Object.values(identities).every(Boolean), "management-page visits retain Sidebar, Composer, actual WorkspacePanel/tree and file preview identity");
const terminalToggle = page.getByRole("button", { name: "Terminal", exact: true }).first();
await terminalToggle.click();
@@ -122,12 +111,11 @@ try {
assert(await closedTerminal.count() === 1, "closed warm terminal remains mounted and hidden");
assert(await page.locator('.terminal-drawer-resizer[tabindex="-1"]').count() === 1, "closed warm terminal is inert and leaves keyboard navigation");
await page.locator('.project-tree__topic-main:has-text("bench:geometry")').click();
await selectSession(page, "bench:geometry");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."));
await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click();
await selectSession(page, "bench:small-6t");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"));
const afterSwitch = await page.evaluate(() => ({
projectTree: window.__appBrowserIdentity.projectTree === document.querySelector(".project-tree"),
workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'),
workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'),
preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'),
@@ -135,7 +123,6 @@ try {
subscriptions: window.__reasonixAppLifecycle?.snapshot().activeSubscriptions,
operations: window.__reasonixAppLifecycle?.snapshot().activeOperations,
}));
assert(afterSwitch.projectTree, "same-project session switching preserves the Sidebar project tree (not WorkspacePanel)");
assert(afterSwitch.workspace && afterSwitch.workspaceTree && afterSwitch.preview && afterSwitch.selectedFile === 'README.md',
'same-project session switching preserves actual WorkspacePanel, tree, preview DOM and selected file');
assert(afterSwitch.subscriptions === 6, `the six AppRuntimeEffects subscriptions remain singular (${afterSwitch.subscriptions})`);
@@ -146,13 +133,13 @@ try {
await page.locator('.remote-surface--ready').waitFor();
await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false);
assert((await page.locator('.topicbar').textContent()).includes('Remote demo session'), "remote project selection adopts its source workspace and authoritative hydrated surface");
await page.locator('.sidebar__quick-action').click();
await newSessionButton(page).click();
await page.waitForFunction(() => document.querySelector('.topicbar')?.textContent?.includes('New session'));
await page.locator('.remote-surface--ready').waitFor();
await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false);
assert(await page.locator('.remote-surface').count() === 1, "global New Session stays on the remote workspace instead of opening a local blank");
assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')), "local/remote navigation and remote New Session preserve the Composer DOM identity");
await page.locator('.project-tree__topic-main:has-text("bench:geometry")').click();
await selectSession(page, "bench:geometry");
await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('Geometry contract fixture complete.'));
assert(await page.locator('.remote-surface').count() === 0, "subsequent local navigation owns the surface; remote events do not reclaim it");
const sentText = 'App source-bound submission fixture';
@@ -1,6 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { memoryAffected, memoryMode } from "./app-memory-paths.mjs";
const benchDir = path.dirname(fileURLToPath(import.meta.url));
test("all frontend consumers, configuration and tests trigger the soak", () => {
for (const path of ["desktop/frontend/src/App.tsx", "desktop/frontend/src/lib/types.ts", "desktop/frontend/pnpm-lock.yaml", "desktop/frontend/bench/fixture.json", ".github/workflows/app-memory.yml"])
assert.equal(memoryAffected([path]), true, path);
@@ -25,3 +30,10 @@ test("ordinary frontend changes use the short screen and lifecycle changes use t
assert.equal(memoryMode(["docs/guide.md"]), "off");
assert.equal(memoryMode(["shared/new-loader.js"]), "full");
});
test("local session benchmarks navigate through the shared layout-independent contract", () => {
for (const name of ["app-memory.mjs", "app-browser.mjs", "composer-transcript-stability.mjs", "dock-view-state.mjs", "run.mjs", "runtime-state.mjs"]) {
const source = readFileSync(path.join(benchDir, name), "utf8");
assert.doesNotMatch(source, /project-tree__topic-main:has-text\(["'`]bench:/, name);
}
});
+14 -9
View File
@@ -7,7 +7,7 @@ import { once } from "node:events";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { startPreviewServer } from "./vite-preview-server.mjs";
import { chooseAppLayout } from "./app-page-actions.mjs";
import { readActiveSessionLabel, selectSession } from "./app-page-actions.mjs";
import { attributeRetention, buildIdentity, evidenceIntegrity, retainedCohorts, screeningBlockers, summarizeHeap } from "./app-memory-evidence.mjs";
import { completeShard, memoryProtocol, protocolSamples, verifyIdentity, MEMORY_FIXTURES } from "./app-memory-shards.mjs";
@@ -63,13 +63,13 @@ async function settleFrames(page, count = 6) {
}
async function selectFixture(page, fixture) {
const active = await timings.measure("navigation.active", () => page.locator(".project-tree__topic--active .project-tree__topic-label").textContent().catch(() => ""));
const active = await timings.measure("navigation.active", () => readActiveSessionLabel(page));
if (active?.includes(fixture.label)) throw new Error(`invalid repeated navigation: ${fixture.label}`);
await timings.measure("navigation.click", () => page.locator(`.project-tree__topic-main:has-text("${fixture.label}")`).click());
await timings.measure("navigation.click", () => selectSession(page, fixture.label));
// Sample a resting page, not a hover card whose 350ms timer races hydration.
await timings.measure("navigation.pointer", () => page.mouse.move(0, 0));
await timings.measure("navigation.ready", () => page.waitForFunction(({ label, marker }) => {
const activeLabel = document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent ?? "";
const activeLabel = document.querySelector('.project-tree__topic--active .project-tree__topic-label')?.textContent ?? "";
const transcript = document.querySelector(".transcript");
return activeLabel.includes(label)
&& transcript?.dataset.transcriptHydrating === "false"
@@ -150,17 +150,22 @@ async function runProcess(index) {
await selectFixture(page, fixtures.full);
await enterSafety(page);
await selectFixture(page, fixtures.full);
for (const [label, className] of [["Creation", "app--creation"], ["Workbench", "app--workbench"]]) {
await chooseAppLayout(page, label, className);
await page.mouse.move(0, 0);
await settleFrames(page);
}
await page.mouse.move(0, 0);
await settleFrames(page);
// Baseline and checkpoints share a post-navigation resting state. Layout
// controls can still own transient listeners immediately after closing, so
// one early reading can sit above the resting value and make every later
// reading look displaced. Settle and require consecutive identical readings
// before accepting the baseline; an unsettled baseline is reported instead
// of being judged as drift.
//
// Warm every measured fixture the same way first. The safety excursion is
// the only earlier windowed visit and it runs with deliberately invalid
// geometry, so without this round trip the windowed surface reaches its
// first healthy render after the baseline and its one-time bounded setup
// is reported as drift. Accumulation is still measured: each phase keeps
// sampling every 32 round trips against this baseline.
await selectFixture(page, fixtures.windowed);
await selectFixture(page, fixtures.geometry);
await selectFixture(page, fixtures.full);
const samples = [];
+38 -8
View File
@@ -1,9 +1,39 @@
/** Shared production page navigation used by behavior and memory fixtures. */
export async function chooseAppLayout(page, label, className) {
await page.locator('button:has(svg.lucide-settings)').last().click();
await page.locator('.settings-screen').waitFor();
await page.locator('.settings-screen .set-seg__btn').filter({ hasText: new RegExp(`^${label}$`) }).click();
await page.locator(`.app.${className}`).waitFor();
await page.locator('.settings-screen .management-screen__back').click();
await page.locator('.settings-screen').waitFor({ state: 'detached' });
/** The desktop ships one workbench layout; this waits for its chrome. */
export async function chooseAppLayout() {
return undefined;
}
/**
* Locate a local session through the workbench's ProjectTree navigation.
*/
export function sessionButton(page, label) {
return page.locator(".project-tree__topic-main").filter({ hasText: label }).first();
}
export async function revealSession(page, label) {
// Composer readiness and sidebar data readiness are independent. Wait for
// the first projected row before deciding whether the target is truncated.
await page.locator(".project-tree__topic-main").first().waitFor({ state: "visible" });
const button = sessionButton(page, label);
await button.waitFor({ state: "visible" });
return button;
}
export async function selectSession(page, label) {
const button = await revealSession(page, label);
await button.click();
}
/** Return the visible new-session action. */
export function newSessionButton(page) {
return page.locator(".sidebar__quick-action:visible").first();
}
export function activeSessionLabel(page) {
return page.locator('.project-tree__topic--active .project-tree__topic-label').first();
}
export async function readActiveSessionLabel(page) {
const label = activeSessionLabel(page);
return await label.count() > 0 ? await label.textContent() ?? "" : "";
}
@@ -0,0 +1,94 @@
#!/usr/bin/env node
// Real app UI and Web Audio, with controlled background runtime snapshots.
import assert from "node:assert/strict";
import path from "node:path";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";
import { selectSession, readActiveSessionLabel } from "./app-page-actions.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
if (process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" ||
(!process.env.PLAYWRIGHT_BROWSERS_PATH && existsSync(path.join(root, ".pw-browsers")))) {
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(root, ".pw-browsers");
}
const { chromium } = await import("playwright");
const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 0 } });
await server.listen();
const url = server.resolvedUrls.local[0];
console.log("Attention browser fixture: " + url);
let browser;
let releaseNotificationModule;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = [];
const notificationModuleGate = new Promise(resolve => { releaseNotificationModule = resolve; });
await page.route("**/src/lib/runtimeNotifications.ts", async route => {
await notificationModuleGate;
await route.continue();
});
page.on("pageerror", error => errors.push(error.message));
page.on("console", message => { if (message.type() === "error") errors.push(message.text()); });
await page.goto(url + "?mock=bench&bench=1", { waitUntil: "domcontentloaded" });
const input = page.locator("textarea.composer__input:not([aria-hidden=true])");
await input.waitFor();
await selectSession(page, "bench:small-6t");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"));
await input.fill("B draft remains here while A asks");
await input.focus();
const selected = await readActiveSessionLabel(page);
await page.evaluate(async () => {
const { runtimeStateStore } = await import("/src/lib/runtimeStateStore.ts");
const { setAttentionPreference } = await import("/src/lib/sound.ts");
setAttentionPreference("synth");
window.__attentionContexts = [];
const NativeAudioContext = window.AudioContext;
window.AudioContext = class extends NativeAudioContext {
constructor(...args) { super(...args); window.__attentionContexts.push(this); }
};
let revision = 0;
window.__publishAttention = (kind = "ask", turnId = "turn-background-A") => {
const state = { schemaVersion: 1, runtimeEpoch: "runtime-A", activityRevision: 1, revision: ++revision,
phase: "executing", running: true, turnId, turnStatus: "in_progress", turnEventSeq: 1, pendingPrompt: true,
pendingInteractions: [{ requestId: "1", kind, turnId, runtimeEpoch: "runtime-A", headId: "head-A" }],
cancelRequested: false, cancellable: true, backgroundJobs: 0, activity: "" };
runtimeStateStore.commit({ epoch: "attention-fixture", revision, sessions: [{ tabId: "detached:A", scope: "global",
workspaceRoot: "/fixture", topicId: "topic-A", sessionPath: "/fixture/a", sessionGeneration: 1,
open: false, remote: false, freshness: "synced", state }], topics: [{ scope: "global", node: {
key: "topic-A", topicId: "topic-A", kind: "global_session", label: "Conversation A", status: "waiting_confirmation",
} }] });
};
window.__publishAttention();
});
assert.equal(await page.evaluate(() => window.__attentionContexts.length), 0, "fixture holds the notification module until a background prompt is pending");
releaseNotificationModule();
await page.getByText("Conversation A is waiting for your answer", { exact: true }).waitFor();
assert.equal(await input.inputValue(), "B draft remains here while A asks");
assert.equal(await readActiveSessionLabel(page), selected);
assert.equal(await input.evaluate(element => element === document.activeElement), true, "notification cannot steal focus");
assert.equal(await page.evaluate(() => window.__attentionContexts.length), 1);
assert.equal(await page.evaluate(() => window.__attentionContexts[0].state === "suspended"), false, "Web Audio must not wait for a tab switch");
const evidence = process.env.REASONIX_ATTENTION_EVIDENCE ?? "/tmp/reasonix-attention-evidence";
await fs.mkdir(evidence, { recursive: true });
await page.screenshot({ path: path.join(evidence, "background-ask.png") });
await page.evaluate(() => window.__publishAttention());
assert.equal(await page.evaluate(() => window.__attentionContexts.length), 1, "repeated snapshot is silent");
await page.evaluate(() => window.__publishAttention("approval", "turn-background-approval"));
await page.getByText("Conversation A is waiting for your approval", { exact: true }).waitFor();
assert.equal(await page.evaluate(() => window.__attentionContexts.length), 2);
await selectSession(page, "bench:geometry");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."));
await selectSession(page, "bench:small-6t");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"));
assert.equal(await input.inputValue(), "B draft remains here while A asks", "draft survives real navigation");
await page.evaluate(() => window.__publishAttention("approval", "turn-background-approval"));
assert.equal(await page.evaluate(() => window.__attentionContexts.length), 2, "navigation must not reset notification identity");
assert.deepEqual(errors, []);
console.log("PASS background Ask and approval, immediate Web Audio, visible toast, focus/draft preservation, navigation and dedupe");
} finally {
releaseNotificationModule?.();
await browser?.close();
await server.close();
}
@@ -325,6 +325,14 @@ try {
await page.waitForTimeout(1000);
const writesAfterIdle = await page.evaluate(() => window.chatWrites.at(-1)?.transaction);
assert.equal(writesAfterIdle, writesBeforeIdle, "settled layout queue converges");
await page.evaluate(() => window.chatFixture.authored());
await page.getByText("我是 Reasonix。", { exact: true }).waitFor();
const authoredText = await page.locator(".chat-column").innerText();
assert.match(authoredText, /你是谁/);
assert.match(authoredText, /旧会话问题/);
assert.match(authoredText, /<response-language>用户引用的 XML<\/response-language>/);
assert.doesNotMatch(authoredText, /private environment|internal policy|legacy internal route|session-context|capability-route/);
await page.screenshot({ path: path.join(evidence, `${name}-authored-chat.png`) });
await page.evaluate(() => window.chatFixture.weather());
await page.locator('[data-chat-anchor-key="weather-final"] table').waitFor();
const presented = page.locator('.presented-files');
+13 -1
View File
@@ -5,6 +5,8 @@ import { LocaleProvider } from "../src/lib/i18n";
import { ToastProvider } from "../src/lib/toast";
import { getMarkdownWorkerClient } from "../src/lib/markdownWorkerClient";
import { Composer } from "../src/components/Composer";
import { canonicalMessage } from "../src/lib/canonicalTranscriptBackend";
import { historyMessagesToItems } from "../src/lib/historyItems";
import type { ControllerLiveStore, Item, LiveStream } from "../src/lib/useController";
import "../src/styles.css";
@@ -32,7 +34,7 @@ function weatherTurn(): Item[] {
{ ...end, id: "weather-final", text: "今天上海天气如下。以下为界面回放测试数据。\n\n## 上海 · 今日实况\n\n| 项目 | 数值 |\n|---|---|\n| 天气 | 晴 ☀️ |\n| 气温 | **25.5 °C** |\n| 湿度 | 66% |\n\n**全天**:多云转晴,23~30 °C。", reasoning: "" } as Item,
];
}
declare global { interface Window { chatFixture: { weather(): void; replace(count: number): void; reset(count: number): void; older(): void; tick(index: number): void; settle(): void; switchSession(): void; prepend(): void; ready: number; pending(): number } } }
declare global { interface Window { chatFixture: { authored(): void; weather(): void; replace(count: number): void; reset(count: number): void; older(): void; tick(index: number): void; settle(): void; switchSession(): void; prepend(): void; ready: number; pending(): number } } }
function Fixture() {
const [items, setItems] = useState(() => makeTurns(20));
const [session, setSession] = useState(0);
@@ -57,6 +59,16 @@ function Fixture() {
useLayoutEffect(() => {
window.chatFixture = {
ready, pending: () => getMarkdownWorkerClient().stats().pending,
authored: () => {
const messages = [
{ role: "user", origin: "host", content: '<session-context version="1">private environment</session-context>' },
{ role: "user", origin: "user", content: '<response-language>internal policy</response-language>你是谁', raw_content: "你是谁" },
{ role: "user", content: '<capability-route>legacy internal route</capability-route>旧会话问题' },
{ role: "user", origin: "user", raw_content: '<response-language>用户引用的 XML</response-language>' },
{ role: "assistant", content: "我是 Reasonix。" },
].map((raw, i) => canonicalMessage({ messageId: `authored-${i}`, position: i, version: 1, role: raw.role }, raw));
clearLive(); setItems(historyMessagesToItems(messages, "authored").items); setRunning(false); setSession(value => value + 1);
},
weather: () => { clearLive(); setItems(weatherTurn()); setRunning(false); setSession(value => value + 1); },
replace: count => { clearLive(); setItems(makeTurns(count)); setRunning(false); setReady(value => value + 1); },
reset: count => { clearLive(); setItems(makeTurns(Math.min(60, count), Math.max(0, count - 60))); setRunning(false); setSession(value => value + 1); setReady(value => value + 1); },
@@ -4,6 +4,7 @@ import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { startPreviewServer } from "./vite-preview-server.mjs";
import { selectSession } from "./app-page-actions.mjs";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
@@ -153,9 +154,9 @@ try {
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await selectSession(page, "bench:tools-38t");
await page.waitForFunction(() => (
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t")
document.querySelector('.project-tree__topic--active .project-tree__topic-label')?.textContent?.includes("bench:tools-38t")
&& document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go")
), undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 30_000 });
+6 -2
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { startPreviewServer } from "./vite-preview-server.mjs";
import { selectSession } from "./app-page-actions.mjs";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
@@ -20,7 +21,7 @@ try {
page.on("pageerror", error => errors.push(error.message));
await page.goto(url + "/?mock=bench&bench=1&app-lifecycle-probe=1");
await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click();
await selectSession(page, "bench:small-6t");
// The benchmark fixture hydrates this session asynchronously. Wait for the
// authoritative session snapshot before mutating dock state, otherwise the
// late hydrate can replace the empty-state picker while Playwright clicks it.
@@ -30,6 +31,9 @@ try {
await overview.waitFor();
assert.equal(await overview.count(), 1, "fresh expanded workspace dock defaults to Overview");
await overview.locator(".workbench-dock__tab-close").click();
await page.locator(".workbench-dock").waitFor({ state: "detached" });
assert.equal(await tabs.count(), 0, "closing the last tab removes the dock");
await page.locator(".topicbar__chrome-btn--workspace[aria-pressed=false]").click();
await page.locator(".tab-picker__item").filter({ hasText: /^Files$/ }).click();
await page.locator('[data-workspace-path="README.md"]').click();
await page.waitForFunction(() => document.querySelector(".workspace-preview__body")?.textContent?.includes("Browser-dev workspace preview."));
@@ -71,7 +75,7 @@ try {
await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click();
await page.locator(".remote-surface--ready").waitFor();
assert.equal(await tabs.count(), 0, "other project does not inherit local dock tabs");
await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click();
await selectSession(page, "bench:small-6t");
await page.locator('[data-workspace-path="README.md"]').waitFor();
assert.equal(await tabs.count(), 2);
assert.equal(await page.evaluate(selected), "README.md", "returning to the project restores its view");
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { startPreviewServer } from "./vite-preview-server.mjs";
import { selectSession } from "./app-page-actions.mjs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(root, ".pw-browsers");
const { chromium } = await import("playwright");
const port = Number(process.env.REASONIX_SIDEBAR_BROWSER_PORT ?? 4679);
const preview = await startPreviewServer(root, port);
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: "en-US" });
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${port}/?mock=bench&bench=1`);
await page.locator(".project-tree__topic-main").first().waitFor();
assert.equal(await page.locator(".app--workbench").count(), 1);
assert.equal(await page.locator(".workspace-browser,.app--creation").count(), 0);
await selectSession(page, "bench:small-6t");
await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('ASYNC LAYOUT EXPANSION COMPLETE'));
assert.ok((await page.locator('.topicbar h1').textContent()).includes('bench:small-6t'));
const geometry = await page.locator('.project-tree__topic-label').first().evaluate(el => ({ whiteSpace: getComputedStyle(el).whiteSpace, overflow: getComputedStyle(el).textOverflow }));
assert.deepEqual(geometry, { whiteSpace: 'nowrap', overflow: 'ellipsis' });
await page.getByRole('button', { name: 'Trash', exact: true }).click();
await page.getByRole('button', { name: 'Archived', exact: true }).click();
await page.locator('.archived-sessions').waitFor();
await page.getByRole('button', { name: 'Back to workspace', exact: true }).click();
await page.locator('.sidebar__utility-button').filter({ hasText: 'Settings' }).click();
await page.locator('.settings-page--general').waitFor();
assert.equal(await page.getByText('Desktop style', { exact: true }).count(), 0);
assert.deepEqual(errors, []);
console.log('PASS workbench-only ProjectTree, compact labels, session navigation, archived recovery and settings');
} finally {
await browser?.close();
await preview.close();
}
+3 -2
View File
@@ -37,6 +37,7 @@ import { existsSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import http from "node:http";
import { selectSession } from "./app-page-actions.mjs";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
// Keep the browser download inside the repo when the caller did not pin one.
@@ -246,7 +247,7 @@ async function coldOpenOnce(browser) {
async function waitForSessionVisible(page, tab, timeoutMs = 15_000) {
await page.waitForFunction(
({ label, marker }) => {
const active = document.querySelector(".project-tree__topic--active .project-tree__topic-label");
const active = document.querySelector('.project-tree__topic--active .project-tree__topic-label');
if (!active || !active.textContent?.includes(label)) return false;
const transcript = document.querySelector(".transcript");
return Boolean(transcript && transcript.textContent?.includes(marker));
@@ -258,7 +259,7 @@ async function waitForSessionVisible(page, tab, timeoutMs = 15_000) {
async function switchTo(page, tab) {
const startedAt = await page.evaluate(() => performance.now());
await page.click(`.project-tree__topic-main:has-text("${tab.label}")`);
await selectSession(page, tab.label);
await waitForSessionVisible(page, tab);
const settledAt = await page.evaluate(() => performance.now());
return settledAt - startedAt;
+7 -8
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";
import { selectSession } from "./app-page-actions.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(root, ".pw-browsers");
const { chromium } = await import("playwright");
@@ -19,7 +20,7 @@ try {
await page.goto("http://127.0.0.1:4668/?mock=bench&bench=1");
const input = page.locator("textarea.composer__input:not([aria-hidden=true])");
await input.waitFor();
await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click();
await selectSession(page, "bench:small-6t");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"));
await page.evaluate(async () => {
const { app, onRemoteTabOpened, onRemoteTabUpdated } = await import("/src/lib/bridge.ts");
@@ -88,10 +89,10 @@ try {
check(calls.length === 1 && queries.length === 1 && calls[0].at(-1) === queries[0].at(-1), "retry only queries the original durable idempotency key");
await publish("idle", { backgroundJobs: 2 });
await page.locator(".composer-run-strip").filter({ hasText: /2/ }).waitFor();
check(await page.locator(".project-tree__folder-active-indicator:not(.project-tree__folder-active-indicator--static)").count() > 0, "background jobs keep project activity visible");
check(await page.locator(".runtime-activity-indicator:not(.runtime-activity-indicator--static)").count() > 0, "background jobs keep project activity visible");
await publish("idle");
await page.locator(".composer-run-strip").waitFor({ state: "hidden" });
check(await page.locator(".project-tree__folder-active-indicator").count() === 0, "last job completion clears project activity");
check(await page.locator(".runtime-activity-indicator").count() === 0, "last job completion clears project activity");
await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click();
await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click();
await page.locator(".remote-surface--ready").waitFor();
@@ -120,11 +121,9 @@ try {
await publish("idle", {}, true);
await page.locator(".composer-run-strip").waitFor({ state: "hidden" });
check(await page.locator(".composer__btn--stop").count() === 0, "remote completion removes the run control");
await page.waitForFunction(() => !document.querySelector('.remote-surface .chat-running')
&& !document.querySelector('.remote-surface .transcript')?.textContent?.includes("runtime missing completion fixture"));
check(await page.locator(".remote-surface").getByText("runtime missing completion fixture", { exact: true }).count() === 0,
"trusted idle without turn_done settles the real transcript and reconciles durable history");
await page.locator('.project-tree__topic-main:has-text("bench:geometry")').click();
check(await page.locator(".remote-surface").getByText("runtime missing completion fixture", { exact: true }).count() === 1,
"ancillary idle cannot erase output before transcript v2 confirms completion");
await selectSession(page, "bench:geometry");
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."));
check(await page.locator(".remote-surface").count() === 0, "local switch retains ownership after remote runtime frames");
check(errors.length === 0, "runtime scenarios produce no browser errors: " + errors.join("; "));
+5 -6
View File
@@ -105,10 +105,9 @@ try {
page.on("pageerror", error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${port}/?mock=deepseek_upgrade&bench=1&platform=${platform}`, { waitUntil: "domcontentloaded" });
await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
for (const layout of [["Creation", "app--creation"], ["Workbench", "app--workbench"]]) {
{
await page.setViewportSize({ width: 1600, height: 1100 });
await page.evaluate(() => { document.documentElement.style.zoom = "1"; });
await chooseAppLayout(page, ...layout);
await page.locator('button:has(svg.lucide-settings)').last().click();
await page.locator(".settings-page--general").waitFor();
await page.getByRole("button", { name: "Expand sound settings", exact: true }).click();
@@ -123,9 +122,9 @@ try {
}
assert.equal(g.statusColumns, 1, `status bar editor owns one full-width column at ${width}px`);
}
console.log(`PASS ${engineName}/${platform}/${layout[0]} general settings`);
console.log(`PASS ${engineName}/${platform} general settings`);
await page.setViewportSize({ width: 1600, height: 1100 });
await verifySaveBars(page, `${engineName}/${platform}/${layout[0]}`);
await verifySaveBars(page, `${engineName}/${platform}`);
await page.getByRole("button", { name: "Model preferences", exact: true }).click();
await page.locator(".model-assignment-row").first().waitFor();
for (const theme of themes) {
@@ -138,7 +137,7 @@ try {
await page.setViewportSize({ width, height: 1100 });
await settle(page);
const g = await page.evaluate(geometry);
const context = `${engineName}/${platform}/${layout[0]}/${theme}/${width}px/${zoom}x`;
const context = `${engineName}/${platform}/${theme}/${width}px/${zoom}x`;
if (g.navigation.searchVisible) {
assert.ok(g.navigation.list.top >= g.navigation.search.bottom, `${context}: navigation viewport stays below search`);
assert.equal(g.navigation.listOverflowY, "auto", `${context}: navigation list owns vertical scrolling`);
@@ -188,7 +187,7 @@ try {
await page.getByRole("listbox", { name: "Default model", exact: true }).waitFor();
await page.keyboard.press("Escape");
await page.getByRole("listbox", { name: "Default model", exact: true }).waitFor({ state: "detached" });
console.log(`PASS ${engineName}/${platform}/${layout[0]} navigation, assignments and picker`);
console.log(`PASS ${engineName}/${platform} navigation, assignments and picker`);
await page.locator(".settings-screen .management-screen__back").click();
}
assert.deepEqual(errors, [], `${engineName}/${platform}: no runtime errors`);
+15 -4
View File
@@ -99,7 +99,7 @@ try {
};
// This first case is the original full-width disappearing-message regression.
await measure("full-width long code");
for (const layout of ["creation", "workbench"]) {
for (const layout of ["workbench"]) {
for (const width of ["standard", "full"]) {
for (const viewport of [760, 820, 1000, 1280, 1920]) {
await setViewport({ width: viewport, height: 1080 });
@@ -113,10 +113,21 @@ try {
await configure({ sidebar, dock, launcher: true });
await measure(`sidebar=${sidebar}/dock=${dock}`);
}
await configure({ layout: "creation", sidebar: false, dock: false, launcher: true });
for (const width of [1009, 1010, 1011]) {
await configure({ layout: "workbench", sidebar: false, dock: false, launcher: true });
// Account for native DPI rounding and the chat pane's border when straddling
// the CSS threshold; assert against the actual rendered surface.
for (const width of [1006, 1010, 1014]) {
await setViewport({ width, height: 1080 });
await page.waitForFunction(hidden => Boolean(document.querySelector(".dock-launcher")) !== hidden, width < 1010);
await page.waitForFunction(() => document.querySelector(".chat-pane").getBoundingClientRect().width >= innerWidth - 2);
const surfaceWidth = await page.locator(".chat-pane").evaluate(element => element.getBoundingClientRect().width);
if (width < 1010) assert.ok(surfaceWidth < 1010, "fixture reaches below launcher threshold");
if (width > 1010) assert.ok(surfaceWidth > 1010, `fixture reaches above launcher threshold: window=${width}, surface=${surfaceWidth}`);
await page.waitForFunction(hidden => Boolean(document.querySelector(".dock-launcher")) !== hidden, surfaceWidth < 1010).catch(async error => {
console.error("launcher threshold geometry", await page.evaluate(() => ({ innerWidth, devicePixelRatio, mode: document.documentElement.dataset.dockLauncher,
surface: document.querySelector(".chat-pane")?.getBoundingClientRect().width,
launcherParent: document.querySelector(".dock-launcher")?.parentElement?.getBoundingClientRect().width })));
throw error;
});
await measure("launcher threshold " + width);
}
await configure({ launcher: false });
+4 -4
View File
@@ -9,7 +9,7 @@ import { installDesktopHostStub } from "../src/__tests__/desktopHostStub";
import "../src/styles.css";
type Options = {
layout: "creation" | "workbench";
layout: "workbench";
width: "standard" | "full";
sidebar: boolean;
dock: boolean;
@@ -54,7 +54,7 @@ const commands = {
function Fixture() {
const t = useT();
const [options, setOptions] = useState<Options>({
layout: "creation", width: "full", sidebar: true, dock: false, launcher: false, long: true, turns: 2, text: null, streaming: false, shell: false,
layout: "workbench", width: "full", sidebar: true, dock: false, launcher: false, long: true, turns: 2, text: null, streaming: false, shell: false,
});
const [revision, setRevision] = useState(0);
const items = useMemo(() => Array.from({ length: options.turns }, (_, index): Item[] => [
@@ -81,12 +81,12 @@ function Fixture() {
footerHeight: 100, invocationMetadata: undefined, surfaceCommitToken: undefined,
liveStore: undefined, transcriptHydrating: false, navigationDataReady: true, readOnly: false,
controllerReady: true, hydratePlaceholderActive: false, clearContextPending: false,
creation: options.layout === "creation", availability: { kind: "ready", source: "history" },
availability: { kind: "ready", source: "history" },
rewind: { stateActive: false, committing: false },
};
return <div className={"app app--windows app--windows-frameless app--" + options.layout} data-fixture-revision={revision}>
<div className={`layout${options.sidebar ? "" : " layout--sidebar-collapsed"}${options.dock ? " layout--workspace-open" : ""}`}
style={{ "--sidebar-expanded-width": options.layout === "creation" ? "236px" : "300px", "--workspace-width": "300px" } as CSSProperties}>
style={{ "--sidebar-expanded-width": "300px", "--workspace-width": "300px" } as CSSProperties}>
<header className="topicbar">Transcript layout fixture</header>
<aside className="sidebar">Sidebar</aside>
<div className="chat-pane">
+3 -2
View File
@@ -22,6 +22,7 @@
"test:mcp-app": "tsx src/__tests__/mcp-app-protocol.test.ts",
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-selection-isolation.test.tsx && tsx src/__tests__/workspace-presented-pagination.test.tsx && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-media-release.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-resize-interaction.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
"test:settings-browser": "node bench/settings-layout.mjs",
"test:sidebar-browser": "node bench/project-tree-sidebar.mjs",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
@@ -29,7 +30,7 @@
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-snapshot-client.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers CHAT_BROWSER=chromium node bench/chat-transcript.mjs",
"test:transcript-reader-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers CHAT_BROWSER=chromium node bench/chat-transcript.mjs",
"test:transcript-electron": "node bench/transcript-layout.mjs --electron",
@@ -50,7 +51,7 @@
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance",
"test:app-lifecycle": "tsx src/__tests__/app-lifecycle.test.tsx && tsx src/__tests__/committed-command-lifecycle.test.tsx && tsx src/__tests__/committed-command-execution.test.tsx && tsx src/__tests__/navigation-surface-lifecycle.test.tsx && tsx src/__tests__/app-lifecycle-probe.test.ts && tsx src/__tests__/subscription-scope.test.ts && tsx src/__tests__/composer-source-operations.test.tsx && tsx src/__tests__/session-prompt-lifecycle.test.tsx && tsx src/__tests__/desktop-preferences-lifecycle.test.tsx && tsx src/__tests__/onboarding-commands.test.tsx && tsx src/__tests__/topicbar-actions-lifecycle.test.tsx && tsx src/__tests__/decision-slots-lifecycle.test.tsx && tsx src/__tests__/session-experience-settings.test.tsx && tsx src/__tests__/project-topic-lifecycle.test.tsx && tsx src/__tests__/conversation-projection.test.ts && tsx src/__tests__/remote-composer-presentation.test.tsx && tsx src/__tests__/remote-composer-commands.test.tsx && tsx src/__tests__/terminal-panel-commands.test.tsx && tsx src/__tests__/workspace-panel-commands.test.tsx && tsx src/__tests__/desktop-navigation-lifecycle.test.tsx && tsx src/__tests__/runtime-status-lifecycle.test.tsx && tsx src/__tests__/session-control-commands.test.ts && tsx src/__tests__/automation-navigation-lifecycle.test.tsx && tsx src/__tests__/mock-remote-catalog.test.ts && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/topicbar-region.test.tsx && tsx src/__tests__/controller-profile-lifecycle.test.tsx && tsx src/__tests__/session-submission-lifecycle.test.tsx && tsx src/__tests__/pending-plan-revision-lifecycle.test.tsx && tsx src/__tests__/session-undo-lifecycle.test.tsx && tsx src/__tests__/session-clear-commands.test.tsx && tsx src/__tests__/turn-verification-commands.test.tsx && tsx src/__tests__/delivery-continue-commands.test.tsx && tsx src/__tests__/active-tab-mirror.test.tsx && tsx src/__tests__/windows-maximised-sync.test.tsx && tsx src/__tests__/topic-summary-commands.test.tsx && tsx src/__tests__/worktree-merge-commands.test.tsx && tsx src/__tests__/composer-insert-commands.test.tsx && node --test bench/app-memory-evidence.test.mjs && node --test bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs && node --test scripts/check-app-layers.test.mjs && node --test scripts/check-desktop-host-boundary.test.mjs && node --test scripts/shell-css.test.mjs && node --test scripts/check-css-syntax.test.mjs",
"test:app-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-browser.mjs && node bench/dock-view-state.mjs && node bench/dock-file-navigation.mjs && node bench/runtime-state.mjs",
"test:app-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-browser.mjs && node bench/dock-view-state.mjs && node bench/dock-file-navigation.mjs && node bench/runtime-state.mjs && node bench/attention-notifications.mjs",
"test:app-memory": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-memory.mjs",
"check:app-layers": "node scripts/check-app-entry-contract.mjs && node scripts/check-app-layers.test.mjs && node scripts/check-app-layers.mjs && node scripts/check-desktop-host-boundary.mjs",
"test:chat": "pnpm test:transcript",
@@ -457,7 +457,13 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// The snapshot orphan reconciliation (content fallback for id-less rebase)
// adds 0.3 KiB raw; the merged payload measures 2437.9 KiB. Same ceiling rule.
// The spectator status reconcile loop and its status/meta plumbing measure
// 2438.1 KiB. Retain the next tenth.
const rawInitialBudgetKiB = 2_438.2;
// 2438.1 KiB on the branch baseline; the merged main baseline below already
// covers them.
// SessionRef bridge methods, active-row identity and the mock hydration event
// contract measure 2439.8 KiB. Extracting exact-tab mock rebinding from the
// startup bridge measures 2439.6 KiB; retain 0.3 KiB bounded toolchain headroom.
// History preparation and cancellation across startup and paging add 884 B
// (0.035%) to the 2498286 B base. Measured 2499170 B; retain 0.1 KiB headroom.
const rawInitialBudgetKiB = 2_440.7;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+3 -3
View File
@@ -54,8 +54,8 @@ export function AppRuntime() {
const { active: remoteSurfaceActive, session: remoteSession, ready: remoteComposerReady, onSend: remoteSend, onCancel: remoteCancel } = useActiveRemoteSession(activeTab, showToast);
const activeSessionIdentity = sessionIdentityKey({
tabId: activeTabId,
session: activeTab?.session ?? state.meta?.session,
sessionPath: activeTab?.sessionPath ?? state.meta?.sessionPath,
sessionId: activeTab?.sessionId ?? state.meta?.sessionId,
sessionGeneration: activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen,
scope: activeTab?.scope,
workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd,
@@ -71,8 +71,8 @@ export function AppRuntime() {
...tabMetas.filter(tab => tab.id !== activeTabId).map(tab => ({
tabId: tab.id,
sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath,
sessionId: tab.sessionId,
sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }),
session: tab.session, sessionGeneration: tab.sessionGeneration,
scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }),
})),
],
});
@@ -251,7 +251,7 @@ ok(
);
ok(
/workbenchChromeHidden\s*=\s*sidebarWorkbench/.test(appViewSource),
/const workbenchChromeHidden = true/.test(appViewSource),
"workbench chrome is hidden for every desktop platform",
);
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { managementDom } from "../test-support/managementDom";
import { installDesktopHostStub } from "./desktopHostStub";
const dom = managementDom();
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { LocaleProvider } = await import("../lib/i18n");
const { ToastProvider } = await import("../lib/toast");
const { ArchivedSessionsList } = await import("../components/ArchivedSessionsList");
const row = (id: string, archived: boolean) => ({ ref: { hostId: "local", sessionId: id }, title: id, preview: "", archived, metadataStatus: "ready" });
const calls: string[] = [];
let restored = false;
let resolveLate: ((value: unknown) => void) | undefined;
const host = installDesktopHostStub({
GetWorkspaceSnapshot: async () => ({ workspaces: [{ id: "hidden", title: "Hidden project", visible: false }] }),
ListWorkspaceSessions: async (id: string, query: string, cursor: string, _limit: number, includeArchived: boolean) => {
assert.equal(includeArchived, true);
calls.push(`${id}:${query}:${cursor}`);
if (query === "late") return new Promise(resolve => { resolveLate = resolve; });
return cursor ? { sessions: restored ? [] : [row("archived", true)], nextCursor: "" }
: { sessions: [row("ordinary", false)], nextCursor: "page-2" };
},
RestoreCanonicalSession: async (ref: { sessionId: string }) => { assert.equal(ref.sessionId, "archived"); restored = true; },
});
const opened: string[] = [];
const root = createRoot(document.getElementById("root")!);
const render = (active = true) => <LocaleProvider><ToastProvider><ArchivedSessionsList active={active} onOpenSession={async ref => { opened.push(ref.sessionId); }} /></ToastProvider></LocaleProvider>;
await act(async () => root.render(render()));
assert.equal(document.querySelectorAll(".archived-sessions__row").length, 1, "archives beyond the first page stay reachable in hidden projects");
assert.ok(calls.includes("hidden::page-2"));
assert.ok(document.body.textContent?.includes("Hidden project"));
await act(async () => (document.querySelector(".archived-sessions__open") as HTMLButtonElement).click());
assert.deepEqual(opened, ["archived"], "archived opens route through the shared navigation owner");
await act(async () => (document.querySelector('[aria-label="Restore session"]') as HTMLButtonElement).click());
assert.equal(document.querySelectorAll(".archived-sessions__row").length, 0);
assert.ok(document.body.textContent?.includes("No archived sessions"));
await act(async () => {
const input = document.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "late");
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
});
assert.ok(resolveLate, "query starts a pending read");
await act(async () => root.render(render(false)));
await act(async () => resolveLate!({ sessions: [row("stale", true)], nextCursor: "" }));
assert.equal(document.querySelectorAll(".archived-sessions__row").length, 0, "hidden page rejects stale reads");
await act(async () => root.unmount());
host.uninstall();
dom.window.close();
console.log("PASS archived pagination, hidden workspace, navigation, restore and stale-load isolation");
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { JSDOM } from "jsdom";
@@ -10,33 +11,35 @@ const noop = () => {};
register(new URL("../../scripts/svg-loader.mjs", import.meta.url));
const { SidebarRegion } = await import("../app-shell/SidebarRegion");
const t = ((key: string) => key) as Translator;
// workbench and creation are the only two styles, and their flags are exclusive.
for (const layout of ["workbench", "creation"]) {
for (const automation of [false, true]) {
const markup = renderToStaticMarkup(<>
<SidebarRegion className="sidebar" workbench={layout === "workbench"}
creation={layout === "creation"} collapsed={false} automation={automation}
navTooltipDisabled searchOpen={false} togglePressed={false} toggleTitle="toggle" t={t}
onNewSession={noop} onOpenTrash={noop} onOpenAutomation={noop} onOpenSettings={noop}
onToggleSearch={noop} onToggle={noop}
resize={{ min: 180, max: 400, value: 240, onPointerDown: noop, onKeyDown: noop, onReset: noop }}
projectTree={{ onOpenTopic: noop, onCreateTopic: noop, onTopicsChanged: noop }} />
<AppBottomRegions terminal={{ surfaceVisible: !automation, open: !automation,
contentVisible: false, remoteSurface: false, t,
panel: { tabId: "tab", open: !automation, onClose: noop },
resizer: { min: 100, max: 400, value: 240, onPointerDown: noop, onKeyDown: noop, onReset: noop },
}} />
</>);
const dom = new JSDOM(markup);
const doc = dom.window.document;
assert.equal(doc.querySelectorAll(".terminal-drawer").length, 1, "terminal host survives page projection");
assert.equal(doc.querySelector(".terminal-drawer")?.hasAttribute("inert"), automation);
assert.equal(doc.querySelectorAll(".terminal-drawer-resizer").length, automation ? 0 : 1);
assert.equal(doc.querySelectorAll(".sidebar-collapse-toggle").length, layout === "creation" && !automation ? 1 : 0);
const automationButtons = [...doc.querySelectorAll("button")].filter(button => button.querySelector(".lucide-alarm-clock"));
assert.equal(automationButtons.length, 1, `${layout} keeps exactly one Automation entry`);
if (layout !== "workbench") assert.equal(automationButtons[0].getAttribute("aria-current"), automation ? "page" : null);
dom.window.close();
}
const topicbarActionsSource = readFileSync(new URL("../app-shell/TopicbarActionsStack.tsx", import.meta.url), "utf8");
assert.doesNotMatch(topicbarActionsSource, /onOpenPalette|\bSearch\b/, "the topic bar no longer owns the command-palette entry");
for (const automation of [false, true]) {
const markup = renderToStaticMarkup(<>
<SidebarRegion className="sidebar" collapsed={false} t={t}
onNewSession={noop} onOpenPalette={noop} paletteShortcut="Cmd+K"
onOpenTrash={noop} onOpenAutomation={noop} onOpenSettings={noop}
resize={{ min: 180, max: 400, value: 240, onPointerDown: noop, onKeyDown: noop, onReset: noop }}
projectTree={{ onOpenTopic: noop, onCreateTopic: noop, onTopicsChanged: noop }} />
<AppBottomRegions terminal={{ surfaceVisible: !automation, open: !automation,
contentVisible: false, remoteSurface: false, t,
panel: { tabId: "tab", open: !automation, onClose: noop },
resizer: { min: 100, max: 400, value: 240, onPointerDown: noop, onKeyDown: noop, onReset: noop },
}} />
</>);
const dom = new JSDOM(markup);
const doc = dom.window.document;
assert.equal(doc.querySelectorAll(".terminal-drawer").length, 1, "terminal host survives page projection");
assert.equal(doc.querySelector(".terminal-drawer")?.hasAttribute("inert"), automation);
assert.equal(doc.querySelectorAll(".terminal-drawer-resizer").length, automation ? 0 : 1);
assert.equal(doc.querySelectorAll(".sidebar-collapse-toggle").length, 0, "the workbench sidebar has no collapse toggle");
const automationButtons = [...doc.querySelectorAll("button")].filter(button => button.querySelector(".lucide-alarm-clock"));
assert.equal(automationButtons.length, 1, "the workbench sidebar keeps exactly one Automation entry");
const trashButtons = [...doc.querySelectorAll("button")].filter(button => button.querySelector(".lucide-trash-2"));
assert.equal(trashButtons.length, 1, "the workbench sidebar keeps the Trash entry");
const searchButtons = [...doc.querySelectorAll("button")].filter(button => button.querySelector(".lucide-search"));
assert.equal(searchButtons.length, 1, "the command-palette search entry moves from the topic bar to the sidebar footer");
assert.ok(searchButtons[0]?.classList.contains("sidebar__utility-button"), "the search entry occupies a sidebar utility slot");
assert.equal(doc.querySelectorAll(".sidebar__utility-button").length, 4, "the sidebar footer allocates one slot to search");
dom.window.close();
}
console.log("automation regions: shared layout page projection passed");
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { rebindMockSessionTab } from "../lib/bridgeMockSessionNavigation";
import type { TabMeta } from "../lib/types";
const source = {
id: "tab-stable",
scope: "project",
workspaceRoot: "/old",
workspaceName: "old",
topicId: "legacy-old",
topicTitle: "Old",
label: "model",
ready: true,
running: false,
mode: "normal",
active: true,
cwd: "/old",
} as TabMeta;
const rebound = rebindMockSessionTab(
{ hostId: "local", sessionId: "canonical-target" }, source,
[{ key: "workspace-new", kind: "project", root: "/new", label: "new", children: [
{ key: "canonical-target", kind: "topic", topicId: "canonical-target", label: "Target" },
] }], "/global", () => false,
);
assert.equal(rebound.id, source.id, "SessionRef navigation preserves the surface identity");
assert.deepEqual(rebound.session, { hostId: "local", sessionId: "canonical-target" });
assert.equal(rebound.sessionId, "canonical-target");
assert.equal(rebound.workspaceId, "project--new");
assert.equal(rebound.topicId, "canonical-target", "legacy fixture metadata follows the rebound session");
assert.throws(() => rebindMockSessionTab(
{ hostId: "local", sessionId: "missing" }, undefined, [], "/global", () => false,
), /not ready/, "navigation cannot invent a replacement surface");
console.log("PASS mock SessionRef navigation rebinds the existing tab identity");
@@ -44,7 +44,7 @@ const electron: ReasonixDesktopHost = {
const modes: RightDockMode[] = [];
const picks: string[] = [];
const props = (mode: RightDockMode): WorkspaceDockRegionProps => ({
visible: true, overlay: false, mode, creation: false, showContext: true,
visible: true, overlay: false, mode, showContext: true,
t: ((key: string) => key) as Translator,
onPickEntry: (entryId: string) => { picks.push(entryId); },
remote: {} as WorkspaceDockRegionProps["remote"], context: {} as WorkspaceDockRegionProps["context"],
@@ -121,8 +121,8 @@ ok(
"Trash and existing session search remain available",
);
ok(
/\.sidebar--workbench\s+\.sidebar__utility-row\s*\{[^}]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/s.test(stylesSource),
"Workbench footer distributes its three utility actions evenly",
/\.sidebar--workbench\s+\.sidebar__utility-row\s*\{[^}]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/s.test(stylesSource),
"Workbench footer distributes its four utility actions evenly",
);
ok(
/\.app--creation\s+\.sidebar__nav,\s*:root\[data-theme-style\]\s+\.app--creation\s+\.sidebar__nav\s*\{[^}]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/s.test(stylesSource),
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { canonicalMessage } from "../lib/canonicalTranscriptBackend";
import { transientUserTags } from "../lib/canonicalUserDisplay";
import { applyResolvedField, entryToRecord } from "../lib/transcriptRecordProjection";
import { historyMessagesToItems } from "../lib/useController";
import type { HistoryContentRef } from "../lib/types";
const persistent = { messageId: "user-1", position: 2, version: 1, role: "user", eventSequence: 2, visibleTurn: 1 };
const context = '<session-context version="1">\nThis host-generated snapshot supersedes every earlier session-context snapshot.\nEnvironment: private host context\n</session-context>';
const wrapped = "<response-language>prefer Chinese</response-language>\n<reasoning-language>中文</reasoning-language>\n你是谁";
const visible = (raw: Record<string, unknown>) => canonicalMessage(persistent, raw);
const items = (raw: Record<string, unknown>) => historyMessagesToItems([visible(raw)], "test").items;
// The existing Go display vocabulary remains authoritative for host wrappers.
const goSource = readFileSync(new URL("../../../../internal/agent/preview.go", import.meta.url), "utf8");
const goTags = [...goSource.match(/var TransientUserBlockTags = \[\]string\{([\s\S]*?)\n\}/)![1].matchAll(/"([\w-]+)"/g)].map(match => match[1]);
assert.deepEqual([...transientUserTags], goTags);
for (const tag of transientUserTags) {
assert.equal(visible({ role: "user", content: `<${tag} version="1">internal</${tag}>\n你是谁` }).content, "你是谁");
}
assert.equal(visible({ role: "user", content: wrapped }).content, "你是谁");
assert.equal(visible({ role: "user", content: wrapped, raw_content: "用户原文" }).content, "用户原文");
assert.equal(visible({ role: "user", raw_content: "只有原文" }).content, "只有原文");
for (const raw of [
{ role: "user", origin: "host", content: context },
{ role: "user", origin: "host", content: "background job protocol", raw_content: "also internal" },
{ role: "user", content: context },
{ role: "user", content: "<response-language>internal only</response-language>" },
]) assert.equal(items(raw).length, 0, JSON.stringify(raw));
for (const literal of [context, "<response-language>user example</response-language>", "```xml\n" + context + "\n```", "Explain <reasoning-language> in XML"]) {
assert.equal(visible({ role: "user", origin: "user", content: wrapped, raw_content: literal }).content, literal);
}
for (const role of ["assistant", "tool"]) assert.equal(canonicalMessage({ ...persistent, role }, { role, content: context }).content, context);
const steerPrefix = "[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]";
assert.equal(visible({ role: "user", content: `${steerPrefix}\n补充说明` }).content, "↪ 补充说明");
assert.equal(visible({ role: "user", content: `${steerPrefix}\n补充说明` }).role, "notice");
assert.equal(items({ role: "user", content: `${steerPrefix}\nA tool failed. Use read-only diagnosis as needed` }).length, 0);
const compiler = `<memory-compiler-execution>${JSON.stringify({ planner_ir: { source_event: wrapped } })}</memory-compiler-execution>`;
assert.equal(visible({ role: "user", content: compiler }).content, "你是谁");
for (const suffix of ["<execution-policy>policy</execution-policy>", "<memory-recall>memory</memory-recall>"]) {
assert.equal(visible({ role: "user", content: `你是谁\n${suffix}` }).content, "你是谁");
}
assert.equal(visible({ role: "user", content: `# Reasonix executor handoff\n\nOriginal task:\n${wrapped}\n\nPlanner output:\nprivate plan` }).content, "你是谁");
// Lazy full-body hydration must use the same projection as inline history.
for (const [origin, expected] of [["host", ""], ["user", "你是谁"]]) {
const raw = { id: persistent.messageId, role: "user", origin, content: `<capability-route>${"x".repeat(40000)}</capability-route>\n你是谁`, raw_content: origin === "user" ? "你是谁" : undefined };
const original = JSON.stringify(raw);
const ref: HistoryContentRef = { entryId: "m:user-1", field: "canonicalMessage", size: original.length, chunks: 1, revision: 1, digest: "fixture" };
const record = entryToRecord({ entryId: "m:user-1", turn: 1, order: 2, message: canonicalMessage({ ...persistent, preview: expected }, undefined), refs: [ref] });
const data = [...new TextEncoder().encode(original)].map(byte => String.fromCharCode(byte)).join("");
assert.equal(applyResolvedField(record, ref, data), true);
assert.equal(record.message.content, expected);
assert.equal(record.message.messageId, persistent.messageId);
assert.equal(JSON.stringify(raw), original, "display never mutates source content");
assert.equal(historyMessagesToItems([record.message], "test").items.length, origin === "host" ? 0 : 1);
}
console.log("PASS authored chat text, host filtering, literal markup, legacy wrappers, steers and lazy hydration");
@@ -129,3 +129,17 @@ assert.deepEqual(noAnswerTail.presentedFiles, [{ path: "result.pdf", toolCallId:
assert.deepEqual(noAnswerTail.modifiedFiles, [{ path: "draft.txt", toolCallId: "write-only", operation: "written" }]);
assert.ok(filesWithoutAnswer.getOrderSnapshot().includes("files-user:tail"));
filesWithoutAnswer.dispose();
const auditSource = new ChatSource("proxy-audit");
auditSource.update({ ...input, running: false, items: [
{ kind: "user", id: "audit-user", text: "inspect" },
{ kind: "notice", id: "audit", code: "capability_proxy_audit", level: "info", text: "proxy target", detail: '{"callId":"call","target":"read"}' },
{ kind: "tool", id: "call", name: "use_capability", args: "{}", readOnly: true, status: "done" },
{ kind: "notice", id: "unassociated", code: "capability_proxy_audit", level: "info", text: "diagnostic only", detail: "{}" },
] });
assert.ok(!auditSource.getOrderSnapshot().includes("audit"), "paired audit must not duplicate the tool row");
const auditProcess = auditSource.getNodeSnapshot("audit-user:process");
assert.ok(auditProcess?.kind === "process" && !auditProcess.members.includes("audit"));
assert.equal(auditSource.toolAudits("call").length, 1, "paired audit stays available in tool details");
assert.ok(auditSource.getOrderSnapshot().includes("unassociated"), "unpaired audit remains available as diagnostics");
auditSource.dispose();
@@ -35,10 +35,14 @@ function deferred<T>() {
console.log("\nclear-session identity race");
// ── hydrate identity fence (used by loadSessionDataForTab.stillCurrent) ─────
ok(hydrateIdentityCurrent("/a.jsonl", 1, "/a.jsonl", 1), "matching path+generation is current");
ok(!hydrateIdentityCurrent("/a.jsonl", 1, "/b.jsonl", 2), "path drift after clear is rejected");
ok(!hydrateIdentityCurrent("/a.jsonl", 1, "/a.jsonl", 2), "generation-only drift after clear is rejected");
ok(hydrateIdentityCurrent("", undefined, "/b.jsonl", 2), "empty load path does not false-reject");
ok(hydrateIdentityCurrent({ sessionPath: "/a.jsonl", sessionGeneration: 1 }, { sessionPath: "/a.jsonl", sessionGeneration: 1 }), "matching path+generation is current");
ok(!hydrateIdentityCurrent({ sessionPath: "/a.jsonl", sessionGeneration: 1 }, { sessionPath: "/b.jsonl", sessionGeneration: 2 }), "path drift after clear is rejected");
ok(!hydrateIdentityCurrent({ sessionPath: "/a.jsonl", sessionGeneration: 1 }, { sessionPath: "/a.jsonl", sessionGeneration: 2 }), "generation-only drift after clear is rejected");
ok(hydrateIdentityCurrent({}, { sessionPath: "/b.jsonl", sessionGeneration: 2 }), "empty load identity does not false-reject");
ok(!hydrateIdentityCurrent(
{ session: { hostId: "local", sessionId: "canonical-a" }, sessionGeneration: 1 },
{ session: { hostId: "local", sessionId: "canonical-b" }, sessionGeneration: 1 },
), "canonical identity drift is rejected when session paths are empty");
// ── deferred A hydrate vs clear→B (barrier interleaving) ───────────────────
type LiveMeta = { sessionPath: string; sessionGeneration: number; items: string[] };
@@ -57,7 +61,10 @@ const hydrateA = (async () => {
const loadGen = live.sessionGeneration;
const page = await lateA.promise;
// stillCurrent check at apply time — equivalent to useController fence.
if (!hydrateIdentityCurrent(loadPath, loadGen, live.sessionPath, live.sessionGeneration)) {
if (!hydrateIdentityCurrent(
{ sessionPath: loadPath, sessionGeneration: loadGen },
{ sessionPath: live.sessionPath, sessionGeneration: live.sessionGeneration },
)) {
return { applied: false as const, items: live.items.slice() };
}
live.items = page.items;
@@ -92,7 +99,12 @@ const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
assert.match(controller, /hydrateIdentityCurrent\(/, "useController uses shared identity fence");
assert.match(controller, /evictTab\(tabId\)/, "clearSession evicts TranscriptStore");
assert.match(controller, /sessionGeneration:\s*cleared\.sessionGeneration/, "clear applies returned generation");
assert.match(controller, /a\.sessionGeneration === b\.sessionGeneration/, "sameMeta compares generation");
assert.match(
controller,
/sessionIdentityStableKey\(a\) === sessionIdentityStableKey\(b\)/,
"sameMeta compares the complete canonical identity through the shared helper",
);
assert.match(controller, /a\.sessionGeneration === b\.sessionGeneration/, "sameMeta also fences unbound generations");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -66,7 +66,7 @@ for (const chatVisible of [false, true]) for (const localToolsEnabled of [false,
const noop = () => {};
const markup = renderToStaticMarkup(createElement(WorkspaceDockRegion, {
visible: layout.dockVisible, overlay: layout.dockOverlay, mode: dockMode,
creation: false, showContext: true, t: ((key: string) => key) as Translator,
showContext: true, t: ((key: string) => key) as Translator,
onPickEntry: noop, remote: { onClose: noop }, context: view.context,
workspaceKey: "fixture", workspace: { open: layout.dockVisible, maximized: false, onClose: noop, onToggleMaximized: noop },
}));
@@ -45,6 +45,7 @@ export async function verifyStaleHistoryFingerprint({
{ revision: 1, digest: "digest-l-v1" },
),
hasOlder: true,
stale: true,
nextCursor: btoa(JSON.stringify({ v: 1, before: 2 })),
});
await act(async () => {
@@ -54,7 +55,7 @@ export async function verifyStaleHistoryFingerprint({
const state = getState();
equal(state?.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false, false, "stale older page is discarded after session fingerprint changes");
equal(state?.historyOlderLoading, false, "stale older page releases its loading state");
equal(state?.historyOlderError, "history identity changed", "stale older page enters the explicit retry state instead of silently auto-retrying");
equal(state?.historyOlderError, "history snapshot expired", "stale older page enters the explicit retry state instead of silently auto-retrying");
}
export async function verifyDeferredHistoryCloseRace({
@@ -29,6 +29,7 @@ const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`),
switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); },
activateTopic: async (_scope, _workspace, id) => activate(id),
openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); },
openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); },
openGlobalTab: async id => { calls.push("tab-global"); return activate(id); },
openProjectTab: async (_workspace, id) => { calls.push("tab-project"); return activate(id); },
@@ -54,9 +55,18 @@ function Probe({ visible = "A" }: { visible?: string }) {
}
const paint = (visible = "A") => act(async () => root.render(<Probe visible={visible} />));
const topic = (id: string) => api.enqueueNavigation({ kind: "topic", scope: "project", workspaceRoot: "fixture", topicId: id });
async function finish(id: string, task: Promise<void>) { pending.get(id)!.resolve(tab(id)); await task; }
async function finish(id: string, task: Promise<void>) {
const deadline = Date.now() + 5000;
while (!pending.has(id) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 1));
assert.ok(pending.has(id), `navigation reached ${id}`);
pending.get(id)!.resolve(tab(id)); await task;
}
try {
await paint();
const canonical = api.enqueueNavigation({ kind: "canonical-session", ref: { hostId: "local", sessionId: "canonical-target" } });
await finish("canonical:canonical-target", canonical);
assert.ok(calls.includes("history-close") && calls.includes("tabs") && calls.includes("reveal-tab"));
calls.length = 0; intent = 0;
const entry = api.enqueueNavigation;
const a = topic("A"), b = topic("B"), c = topic("C");
await b;
@@ -27,14 +27,13 @@ const desktopStub = installDesktopHostStub(appStubTable);
let current!: ReturnType<typeof useDesktopPreferences>;
function Probe() { current = useDesktopPreferences(); return <div>{current.configLoadWarnings.join("|")}</div>; }
const root = createRoot(document.getElementById("root")!);
const snapshot = { sessionExperience: "deep", desktopLayoutStyle: "creation", desktopTheme: "light", desktopThemeStyle: "graphite",
const snapshot = { sessionExperience: "deep", desktopTheme: "light", desktopThemeStyle: "graphite",
desktopLanguage: "en", checkUpdates: true, configWarnings: ["warning"], configWarningsRevision: 3 } as DesktopStartupSettingsView;
try {
localStorage.setItem("reasonix-process-fold", "auto");
await act(async () => root.render(<LocaleProvider><Probe /></LocaleProvider>));
assert.equal(requests, 1);
await act(async () => { resolveStartup(snapshot); await import("../lib/themeExperience"); });
assert.equal(current.desktopLayoutStyle, "creation");
assert.equal(getSessionExperience(), "deep", "backend wins over an old localStorage mirror");
assert.deepEqual(current.configLoadWarnings, ["warning"]);
await act(async () => { desktopStub.emit("config:load-warnings", ["stale"], 2); });
@@ -4,7 +4,7 @@
// host.invoke through it, and mutating the table between calls is observed
// immediately (mirroring how the retired window.go seam behaved).
import type { AppBindings } from "../lib/bridge";
import { makeMockSessionReaderBindings } from "../lib/sessionReaderBridge";
import { makeMockSessionReaderBindings, publishMockTranscriptEvent, setMockTranscriptMetadata } from "../lib/sessionReaderBridge";
import type { NativePerformanceActions, ProcessDiagnosticsSnapshot } from "../lib/processDiagnostics";
import type { DesktopBrowserHost } from "../lib/browserHost";
import type { BrowserControlApi, BrowserControlState, ChromeImportOutcome, ReasonixDesktopHost } from "../lib/desktopHost";
@@ -77,7 +77,7 @@ export interface DesktopHostStub {
export function installDesktopHostStub(commands: object, options: DesktopHostStubOptions = {}): DesktopHostStub {
const ref = { current: commands as Record<string, unknown> };
const readerFallback = () => Object.prototype.hasOwnProperty.call(ref.current, "SessionOpenForTab") || typeof ref.current.TranscriptSnapshotForTab === "function" ? {} : makeMockSessionReaderBindings();
const readerFallback = () => typeof ref.current.SessionOpenForTab === "function" || typeof ref.current.TranscriptSnapshotForTab === "function" ? {} : makeMockSessionReaderBindings();
const events = new Map<string, Set<(...data: unknown[]) => void>>();
const host: ReasonixDesktopHost = {
kind: "electron",
@@ -94,7 +94,11 @@ export function installDesktopHostStub(commands: object, options: DesktopHostStu
invoke: (method, args) => {
const fn = ref.current[method] ?? (readerFallback() as Record<string, unknown>)[method];
if (typeof fn !== "function") return Promise.reject(new Error(`unstubbed desktop command ${method}`));
return Promise.resolve((fn as (...a: unknown[]) => unknown).apply(ref.current, args));
return Promise.resolve((fn as (...a: unknown[]) => unknown).apply(ref.current, args)).then(result => {
if (method === "ListTabs" && Array.isArray(result)) for (const tab of result) setMockTranscriptMetadata(tab.id, tab);
if (method === "MetaForTab" && result) setMockTranscriptMetadata(String(args[0]), result);
return result;
});
},
on: (name, cb) => {
let set = events.get(name);
@@ -160,6 +164,13 @@ export function installDesktopHostStub(commands: object, options: DesktopHostStu
},
events,
emit(name, ...data) {
if (name === "runtime:rebuilt" && data[0] && data[1]) setMockTranscriptMetadata(String(data[0]), { runtime: { epoch: String(data[1]) } });
if (name === "agent:event" && data[0]) publishMockTranscriptEvent(data[0] as import("../lib/types").WireEvent);
const remote = /^remote-tab:(.+):event$/.exec(name);
if (remote && data[0]) {
const event = data[0] as import("../lib/types").WireEvent & { reasoning?: string };
publishMockTranscriptEvent({ ...event, tabId: remote[1], text: event.kind === "reasoning" ? event.reasoning ?? event.text : event.text });
}
for (const cb of [...(events.get(name) ?? [])]) cb(...data);
},
replaceCommands(next) {
@@ -26,7 +26,7 @@ const { dom, root } = await renderFilesWorkspace({
ResolveRemotePresentedPathForTab: async (_tab, _host, _tool, path) => path,
});
const props: WorkspaceDockRegionProps = {
visible: false, overlay: false, mode: "files", creation: false, showContext: false,
visible: false, overlay: false, mode: "files", showContext: false,
t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"],
workspace: { open: true, tabId: "navigation-session", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} },
workspaceKey: "navigation-test", workspaceRoot: "/repo",
@@ -28,7 +28,7 @@ const { dom, root, dockTabId } = await renderFilesWorkspace({
ListDirForTab: async (_tab, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [],
});
const props: WorkspaceDockRegionProps = {
visible: true, overlay: false, mode: "files", creation: false, showContext: false,
visible: true, overlay: false, mode: "files", showContext: false,
t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"],
workspace: { open: true, tabId: "navigation-session", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} },
workspaceKey: "tabs-test", workspaceRoot: "/repo",
@@ -29,7 +29,7 @@ const { dom, root, dockTabId } = await renderFilesWorkspace({
ReadPresentedFileSourceForTab: async (_tab, _tool, path) => preview(path),
});
const props: WorkspaceDockRegionProps = {
visible: true, overlay: false, mode: "files", creation: false, showContext: false,
visible: true, overlay: false, mode: "files", showContext: false,
t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"],
workspace: { open: true, tabId: "tab-a", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} },
workspaceKey: "lifecycle", workspaceRoot: "/repo", fileNavigation: fileNavigationOwner(),
@@ -18,7 +18,7 @@ const { dom, root, dockTabId } = await renderFilesWorkspace({
ListDirForTab: async () => [],
});
const props: WorkspaceDockRegionProps = {
visible: true, overlay: false, mode: "files", creation: false, showContext: false,
visible: true, overlay: false, mode: "files", showContext: false,
t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"],
workspace: { open: true, tabId: "tab-one", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} },
workspaceKey: "revision-test", workspaceRoot: "/repo",
@@ -25,7 +25,7 @@ const { dom, root, dockTabId } = await renderFilesWorkspace({
ListDirForTab: async (_tab, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [],
});
const props: WorkspaceDockRegionProps = {
visible: true, overlay: false, mode: "files", creation: false, showContext: false,
visible: true, overlay: false, mode: "files", showContext: false,
t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"],
workspace: { open: true, tabId: "session-tab", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} },
workspaceKey: "session-test", workspaceRoot: "/repo",
@@ -77,7 +77,7 @@ eq(finalDeclaration(".slashmenu", "max-height"), "min(360px, var(--composer-menu
// AC4 — App.tsx applies footer--decision exactly when a decision surface owns
// the footer, and the pre-existing footer--compact wiring stays intact.
eq(/visibleDecisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on the visible decision surface");
eq(/terminalSurfaceOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
eq(/terminalSurfaceOpen \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
eq(/ref=\{composerWrapRef\}/.test(composerSource), true, "Composer measures the real menu anchor");
eq(/observeComposerMenuViewport\(anchor\)/.test(composerSource), true, "Composer observes geometry while a menu is open");
@@ -0,0 +1,27 @@
import { act } from "react";
import type { useController } from "../../lib/useController";
export async function verifyExplicitTranscriptRetry(options: {
controller: () => ReturnType<typeof useController> | undefined;
historyCalls: () => number;
ancillaryCalls: () => number;
releaseAncillary: () => void;
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
flush: () => Promise<void>;
ok: (value: boolean, label: string) => void;
}) {
const { controller, historyCalls, ancillaryCalls, releaseAncillary, waitFor, flush, ok } = options;
await waitFor("Follow cut remains independent of metadata", () => controller()?.state.items.some(item => item.kind === "user" && item.text === "stale M v1") ?? false);
ok(historyCalls() === 1, "metadata does not attach a later version to earlier body data");
await waitFor("old ancillary read is pending", () => ancillaryCalls() === 1);
let retry: Promise<void> | undefined;
await act(async () => { retry = controller()?.retrySessionHistory("tab-m"); await flush(); });
try {
await waitFor("explicit retry requests a fresh cut before ancillary completion", () => historyCalls() === 2);
await act(async () => { await retry; await flush(); });
ok(controller()?.state.items.some(item => item.kind === "user" && item.text === "history M v2") === true, "explicit retry installs the new snapshot");
} finally {
await act(async () => { releaseAncillary(); await flush(); });
}
ok(controller()?.state.context.used !== 99999, "superseded ancillary completion cannot overwrite the retry");
}
@@ -15,8 +15,8 @@ export async function runRemoteRuntimeCases({ commands, emitRemote: __emitMockRe
flush: () => Promise<void>;
setSnapshotHistory: (history: unknown[]) => void;
}) {
// A lost turn_done must settle the actual shared Transcript from runtime
// evidence, while a late history response must not erase the next turn.
// Only the ordered Follow stream settles transcript activity. Ancillary
// status cannot replace its content or settle a newer turn.
const { runtimeStateStore } = await import("../../lib/runtimeStateStore");
let runtimeProbe: RemoteSessionApi;
function RuntimeProbe() {
@@ -43,7 +43,7 @@ export async function runRemoteRuntimeCases({ commands, emitRemote: __emitMockRe
}
await act(async () => {
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_started", turnId: "lost-turn" });
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", text: "partial runtime answer" });
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", messageId: "runtime-answer", text: "partial runtime answer" });
await flush();
});
const beforeRuntimeHistory = tape.filter(entry => entry === "snapshot:tab-runtime").length;
@@ -60,51 +60,34 @@ export async function runRemoteRuntimeCases({ commands, emitRemote: __emitMockRe
await flush();
});
ok(runtimeProbe!.transcript.retry !== undefined, "lost-completion fixture includes an active retry");
setSnapshotHistory([{ role: "assistant", content: "complete durable runtime answer" }]);
setSnapshotHistory([{ role: "assistant", content: "obsolete metadata history" }]);
await publishRuntime("idle");
await act(async () => flush());
ok(!runtimeProbe!.running && !runtimeProbe!.transcript.running && !runtimeProbe!.transcript.turnActive
&& runtimeProbe!.transcript.live === undefined && runtimeProbe!.transcript.retry === undefined,
"trusted idle settles transcript, live stream, and turn activity together");
ok(runtimeNode.textContent?.includes("complete durable runtime answer") === true
&& !runtimeNode.querySelector('[data-transcript-block-phase="active"]'),
"lost completion restores durable content and removes the actual active DOM state");
await publishRuntime("idle");
ok(tape.filter(entry => entry === "snapshot:tab-runtime").length === beforeRuntimeHistory + 1,
"ordinary idle revisions do not repeatedly reload history");
await act(async () => { await runtimeProbe!.submit("next optimistic turn"); await flush(); });
await publishRuntime("idle");
ok(runtimeProbe!.transcript.running, "a newer idle revision of the old turn cannot settle an optimistic submission");
ok(runtimeProbe!.transcript.running && runtimeProbe!.transcript.live?.text === "partial runtime answer",
"ancillary idle cannot settle or erase an active Follow stream");
ok(tape.filter(entry => entry === "snapshot:tab-runtime").length === beforeRuntimeHistory,
"runtime metadata never initiates a completion history rebase");
await act(async () => {
__emitMockRemoteTab("tab-runtime", "event", { kind: "message", messageId: "runtime-answer", text: "complete durable runtime answer" });
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_done", turnId: "lost-turn" });
await flush();
});
ok(!runtimeProbe!.transcript.running && !runtimeProbe!.transcript.turnActive && runtimeProbe!.transcript.retry === undefined,
"Follow completion settles activity and retry together");
ok(runtimeNode.textContent?.includes("complete durable runtime answer") === true,
"committed final content remains visible after Follow completion");
await act(async () => {
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_started", turnId: "next-turn" });
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", text: "next live answer" });
await flush();
});
await publishRuntime("idle");
ok(runtimeProbe!.transcript.running, "old idle remains fenced after the next turn_started");
const originalSnapshot = commands.RemoteTabSnapshot;
let releaseRuntimeHistory: ((value: Awaited<ReturnType<AppBindings["RemoteTabSnapshot"]>>) => void) | undefined;
commands.RemoteTabSnapshot = async () => new Promise(resolve => { releaseRuntimeHistory = resolve; });
await publishRuntime("idle", "next-turn");
await act(async () => {
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_started", turnId: "third-turn" });
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", text: "third live answer" });
await flush();
releaseRuntimeHistory?.({ history: [{ role: "assistant", content: "obsolete history" }] });
await flush();
});
ok(runtimeProbe!.transcript.running && runtimeProbe!.transcript.live?.text === "third live answer"
&& !runtimeProbe!.transcript.items.some(item => item.kind === "assistant" && item.text === "obsolete history"),
"history reconciliation cannot replace a newer streaming turn");
await publishRuntime("idle", "third-turn");
await act(async () => {
__emitMockRemoteTab("tab-runtime", "event", { kind: "usage", turnId: "third-turn", usage: { promptTokens: 12, completionTokens: 3 } });
releaseRuntimeHistory?.({ history: [{ role: "assistant", content: "third durable answer" }] });
await flush();
});
ok(runtimeProbe!.transcript.items.some(item => item.kind === "assistant" && item.text === "third durable answer"),
"late telemetry from the settled turn does not discard durable history");
commands.RemoteTabSnapshot = originalSnapshot;
await publishRuntime("idle", "lost-turn");
ok(runtimeProbe!.transcript.running && runtimeProbe!.transcript.live?.text === "next live answer",
"old idle cannot settle the next Follow turn");
const beforeStatus = tape.filter(entry => entry === "snapshot:tab-runtime").length;
await act(async () => { await commands.RemoteTabStatus("tab-runtime"); await flush(); });
ok(tape.filter(entry => entry === "snapshot:tab-runtime").length === beforeStatus
&& !runtimeNode.textContent?.includes("obsolete metadata history"),
"late status cannot replace the transcript with unrelated history");
await act(async () => runtimeRoot.unmount());
runtimeNode.remove();
}
@@ -0,0 +1,15 @@
import type { AppBindings } from "../../lib/bridge";
import { publishMockTranscriptEvent } from "../../lib/sessionReaderBridge";
import type { WireEvent } from "../../lib/types";
/** Feed fixture history through Follow; metadata never initiates another cut. */
export function installRemoteTranscriptFixture(commands: Record<string, unknown>): void {
const snapshots = new Map<string, Awaited<ReturnType<AppBindings["RemoteTabSnapshot"]>>>();
commands.HistoryForTab = async (tabId: string) => {
const snapshot = await (commands.RemoteTabSnapshot as AppBindings["RemoteTabSnapshot"])(tabId);
snapshots.set(tabId, snapshot);
for (const event of snapshot.pendingEvents ?? []) publishMockTranscriptEvent({ ...(event as WireEvent), tabId });
return snapshot.history ?? [];
};
commands.RemoteTabMetadata = async (tabId: string) => snapshots.get(tabId) ?? {};
}
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fetchPreparedHistorySlice } from "../lib/transcriptHistoryFetch";
import type { HistorySlice } from "../lib/types";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
@@ -16,13 +18,18 @@ assert.match(controller, /applyHydrateErrorState|hydratePlaceholderItems/, "hydr
assert.match(readFileSync(join(root, "lib/hydrateErrorState.ts"), "utf8"), /keptItems/, "hydrateErrorState preserves items");
assert.match(controller, /throw new Error\(t\("history\.failedLoadHistory"\)\)/, "listSessions does not swallow failures as empty");
assert.match(controller, /retrySessionHistory/, "retry path is exported");
assert.match(controller, /shouldPreferResidentHistory\(resetSurface, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
assert.match(controller, /startTranscriptFollow/, "retry establishes an authoritative Follow snapshot");
assert.match(
controller,
/loadSessionDataForTab\(tabId, false, "startup", \{ preserveCachedHistory: true \}\)/,
"failed clear keeps the visible transcript instead of a resident snapshot",
);
assert.match(store, /slice\.error/, "transcript store rejects slice.error as failure");
assert.match(store, /fetchPreparedHistorySlice/, "transcript store uses the shared history reader");
await assert.rejects(
fetchPreparedHistorySlice(async () => ({ entries: [], error: " history unavailable " }) as unknown as HistorySlice, () => true),
{ message: "history unavailable" },
"history reader rejects slice errors instead of treating them as empty history",
);
assert.match(appView, /retrySessionHistory/, "App wires history retry control");
assert.match(chatPane, /SessionRecoveryBanner/, "App surfaces persistent history recovery above the transcript");
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { HistoryPreparingError, loadPreparedHistory } from "../lib/historyPreparation";
import { TranscriptStore } from "../lib/transcriptStore";
import type { HistorySlice } from "../lib/types";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { canonicalHistorySlice } from "../lib/canonicalTranscriptBackend";
let calls = 0;
let waits = 0;
const page = await loadPreparedHistory(async () => {
if (++calls < 3) throw new HistoryPreparingError();
return "ready";
}, () => true, async () => { waits++; });
assert.equal(page, "ready");
assert.equal(waits, 2);
let current = true;
let cancelledCalls = 0;
const cancelled = await loadPreparedHistory(async () => {
cancelledCalls++;
throw new HistoryPreparingError();
}, () => current, async () => { current = false; });
assert.equal(cancelled, undefined);
assert.equal(cancelledCalls, 1, "session changes stop preparation requests");
const corruption = new Error("damaged session store");
await assert.rejects(loadPreparedHistory(async () => { throw corruption; }, () => true,
async () => { assert.fail("real errors must not be retried"); }), error => error === corruption);
let finish!: (value: string) => void;
current = true;
const pending = loadPreparedHistory(() => new Promise<string>(resolve => { finish = resolve; }), () => current);
current = false;
finish("old session");
assert.equal(await pending, undefined, "late ready response cannot hydrate another session");
const readySlice: HistorySlice = { entries: [{ entryId: "m1", turn: 1, order: 1, message: { role: "user", content: "ready history" }, refs: [] }], nextCursor: "", hasOlder: false, totalTurns: 1, startTurn: 1, endTurn: 1, stale: false, revision: 1 };
let preparationCalls = 0;
const store = new TranscriptStore({
HistorySliceForTab: async () => { if (++preparationCalls === 1) throw new HistoryPreparingError(); return readySlice; },
HistoryContentForTab: async () => { throw new Error("unused"); },
}, { preparationWait: async () => {} });
const loaded = await store.loadLatest("tab", "session");
assert.equal(loaded?.totalTurns, 1, "store completes startup hydration after preparation");
assert.equal(preparationCalls, 2);
let resume!: () => void;
let waitStarted!: () => void;
const started = new Promise<void>(resolve => { waitStarted = resolve; });
let staleCalls = 0;
const changingStore = new TranscriptStore({
HistorySliceForTab: async () => { if (++staleCalls === 1) throw new HistoryPreparingError(); return readySlice; },
HistoryContentForTab: async () => { throw new Error("unused"); },
}, { preparationWait: () => new Promise<void>(resolve => { resume = resolve; waitStarted(); }) });
const oldLoad = changingStore.loadLatest("tab", "session");
await started;
const newLoad = await changingStore.loadLatest("tab", "session");
resume();
assert.equal(await oldLoad, undefined, "superseded store generation cannot restart its request");
assert.equal(newLoad?.totalTurns, 1);
assert.equal(staleCalls, 2, "superseded request performs no extra RPC");
let pagingCalls = 0;
current = true;
const pagingStore = new TranscriptStore({
HistorySliceForTab: async (_tab, req) => {
assert.equal("current" in req, false, "lifecycle callback cannot enter the IPC payload");
if (++pagingCalls > 1) throw new HistoryPreparingError();
return { ...readySlice, hasOlder: true, nextCursor: "older" };
},
HistoryContentForTab: async () => { throw new Error("unused"); },
}, { preparationWait: async () => { current = false; } });
await pagingStore.loadLatest("tab", "session");
assert.equal(await pagingStore.loadOlder("tab", "session", { current: () => current }), undefined);
assert.equal(pagingCalls, 2, "navigation cancels preparation of an older page");
const dom = new JSDOM("", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
let remoteReads = 0;
const host = installDesktopHostStub({
SessionHistoryWindowForTab: async () => ({ status: "preparing", messages: [] }),
RemoteSessionHistoryWindowForTab: async () => { remoteReads++; throw new Error("wrong host"); },
});
await assert.rejects(canonicalHistorySlice("preparing-local", { cursor: "" }), HistoryPreparingError);
assert.equal(remoteReads, 0, "preparing local history must not fall back to remote");
host.uninstall();
dom.window.close();
console.log("history preparation: ready, cancellation, late completion and real errors passed");
@@ -96,6 +96,27 @@ ok(
!sameSessionHydrateIdentity({ sessionPath: "" }, { sessionPath: "" }),
"empty identities cannot prove the same session",
);
ok(
sameSessionHydrateIdentity(
{ session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "", sessionGeneration: 3 },
{ session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "", sessionGeneration: 3 },
),
"matching SessionRefs prove a canonical session even when both paths are empty",
);
ok(
!sameSessionHydrateIdentity(
{ session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "" },
{ session: { hostId: "local", sessionId: "canonical-b" }, sessionPath: "" },
),
"different canonical SessionRefs never share an empty-path surface",
);
ok(
!sameSessionHydrateIdentity(
{ session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "same.jsonl" },
{ session: { hostId: "local", sessionId: "canonical-b" }, sessionPath: "same.jsonl" },
),
"SessionRef disagreement outranks a matching compatibility path",
);
const sameSessionPlan = activeTabHydrationPlan(
{ sessionPath: "a.jsonl", sessionGeneration: 3, sessionRevision: 8, sessionDigest: "rev-8" },
{ sessionPath: "a.jsonl", sessionGeneration: 3 },
@@ -58,8 +58,8 @@ ok(!/ModalCloseButton autoFocus/.test(mergeModal), "merge modal captures its tri
ok(/CloseMergedWorktreeTab\(request: CloseMergedWorktreeTabRequest\)/.test(bridge), "worktree close is a request-object bridge call");
ok(/FinalizeWorktreeMerge\(request: WorktreeCleanupRequest\)/.test(bridge), "cleanup is a separate request-object bridge call");
const fencedNavigationCalls = [
["const resumeSession", "app.ResumeSessionPage"],
["const openChannelSession", "app.OpenChannelSessionPageForTab"],
["const resumeSession", "app.ResumeTranscriptSessionForTab"],
["const openChannelSession", "app.OpenChannelTranscriptSessionForTab"],
["const pickWorkspace", "app.PickWorkspace"],
["const switchWorkspace", "app.SwitchWorkspace"],
["const switchTab", "app.SetActiveTab"],
@@ -1,50 +0,0 @@
// Run: tsx src/__tests__/layout-style-defaults.test.ts
import { JSDOM } from "jsdom";
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("\nlayout style defaults");
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
Object.defineProperty(dom.window, "innerWidth", { configurable: true, value: 1920 });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
const layout = await import("../store/layout");
eq(layout.defaultSidebarWidth(), 300, "wide classic default remains responsive");
eq(layout.useLayoutStore.getState().sidebarWidth, 300, "shared store starts from the classic sidebar default");
eq(layout.useLayoutStore.getState().rightDockTreeWidth, 300, "shared store starts from the classic dock default");
layout.applyLayoutStyleDefaults("creation");
eq(layout.useLayoutStore.getState().sidebarWidth, 236, "Creation applies its sidebar default after style hydration");
eq(layout.useLayoutStore.getState().rightDockTreeWidth, 252, "Creation applies its dock default after style hydration");
eq(dom.window.localStorage.getItem("reasonix.layoutPreferences.v1"), null, "applying defaults does not overwrite user preferences");
layout.applyLayoutStyleDefaults("workbench");
eq(layout.useLayoutStore.getState().sidebarWidth, 300, "switching to workbench restores its responsive sidebar default");
eq(layout.useLayoutStore.getState().rightDockTreeWidth, 300, "switching to workbench restores its dock default");
layout.saveSidebarWidth(286);
layout.saveRightDockTreeWidth(344);
layout.useLayoutStore.getState().setSidebarWidth(286);
layout.useLayoutStore.getState().setRightDockTreeWidth(344);
layout.applyLayoutStyleDefaults("creation");
eq(layout.useLayoutStore.getState().sidebarWidth, 286, "Creation preserves a saved sidebar width");
eq(layout.useLayoutStore.getState().rightDockTreeWidth, 344, "Creation preserves a saved dock width");
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -523,6 +523,22 @@ console.log("\nmermaid rendering");
const renders: Array<{ definition: string; theme: string }> = [];
const panZoomCalls: string[] = [];
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
dom.window.requestAnimationFrame = (callback) => {
frames.set(++frameId, callback);
return frameId;
};
dom.window.cancelAnimationFrame = (id) => { frames.delete(id); };
const advanceFrame = async () => {
await act(async () => {
const current = [...frames.entries()];
for (const [id, callback] of current) {
if (!frames.delete(id)) continue;
callback(dom.window.performance.now());
}
});
};
__setMermaidRenderAdapterForTest(async (_svgId, definition, theme, signal) => {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -570,7 +586,13 @@ console.log("\nmermaid rendering");
ok(!document.querySelector(".mermaid-diagram__preview svg")?.hasAttribute("onload"), "rendered SVG root event handler is stripped");
ok(!document.querySelector(".mermaid-diagram__preview script"), "rendered SVG script nodes are removed");
await waitFor("pan zoom instance initialized", () => panZoomCalls.includes("fit") && panZoomCalls.includes("center"));
// Initialization and layout sync own separate animation frames. Advance
// those frames explicitly instead of racing JSDOM's 60 Hz clock with timers.
eq(panZoomCalls.length, 0, "pan zoom waits for its initialization frame");
await advanceFrame();
ok(!panZoomCalls.includes("fit"), "pan zoom layout waits for the following frame");
await advanceFrame();
ok(panZoomCalls.includes("fit") && panZoomCalls.includes("center"), "pan zoom instance initialized");
const zoomIn = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]');
const zoomOut = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]');
@@ -105,6 +105,7 @@ const staleHistory = deferred<HistoryMessage[]>();
const staleSessionMeta = deferred<Meta>();
let newSessionCalls = 0;
let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }];
let backendHistory: HistoryMessage[] | undefined;
let holdNextMeta = false;
let staleMetaStarted = false;
let backendRuntimeEpoch = "runtime-old";
@@ -147,7 +148,7 @@ const appStubTable = {
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
historySliceFromMessages(tabID, await staleHistory.promise, req),
historySliceFromMessages(tabID, backendHistory ?? await staleHistory.promise, req),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
ReplayPendingPromptsForTab: async (tabID: string) => {
@@ -168,8 +169,10 @@ const appStubTable = {
if (tabID !== "tab-a") throw new Error(`unexpected new-session target ${tabID}`);
newSessionCalls += 1;
backendCanonicalTodos = [];
backendHistory = [];
},
ResumeSessionPageForTab: async () => {
ResumeTranscriptSessionForTab: async () => {
backendHistory = [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }];
backendCanonicalTodos = [{ content: "Restored task", status: "completed" }];
const oldEpoch = backendRuntimeEpoch;
backendRuntimeEpoch = "runtime-resumed";
@@ -196,7 +199,8 @@ desktopStub.emit("agent:event", {
hasOlder: false,
};
},
OpenChannelSessionPageForTab: async () => {
OpenChannelTranscriptSessionForTab: async () => {
backendHistory = [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }];
const oldEpoch = backendRuntimeEpoch;
backendRuntimeEpoch = "runtime-channel";
backendPendingPrompt = true;
@@ -287,7 +291,7 @@ await act(async () => {
await flushPromises();
});
eq(resumeSurfaceSettled, false, "Resume releases navigation acquisition before target history settles");
eq(controller?.state.ask?.id, "pre-response-resume", "Resume accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
eq(controller?.state.ask?.id, undefined, "Resume waits for a consistent Follow snapshot before presenting prompts");
await act(async () => {
resumeRPCGate.resolve();
await resumeNavigation?.surfaceReady;
@@ -309,7 +313,7 @@ await act(async () => {
channelNavigation = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
await flushPromises();
});
eq(controller?.state.ask?.id, "pre-response-channel", "channel-open accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
eq(controller?.state.ask?.id, undefined, "channel-open waits for a consistent Follow snapshot before presenting prompts");
await act(async () => {
channelRPCGate.resolve();
await channelNavigation?.surfaceReady;
@@ -574,11 +578,9 @@ desktopStub.replaceCommands({
ResumeSessionPageForTab: legacyRead, OpenChannelSessionPageForTab: legacyRead,
ReplayPendingPrompts: async () => {},
ReplayPendingPromptsForTab: async () => {},
TranscriptSnapshotForTab: async () => {
SessionHistoryWindowForTab: async () => {
modernSnapshots++;
return { protocolVersion: 1, snapshotId: modernEpoch, identity: { sessionId: modernEpoch, headId: modernEpoch, rewriteEpoch: 0, runtimeEpoch: modernEpoch },
projectionRevision: 0, coveredThroughSeq: 0, records: [], activeRecords: [], runtime: { status: "completed", pendingEvents: [] },
activeAttempts: [], before: 0, hasOlder: false, totalRecords: 0, totalTurns: 0, stale: false };
return { status: "ready", snapshotSequence: 0, coverageSequence: 0, generation: modernEpoch, messages: [], totalTurns: 0, hasOlder: false, hasNewer: false };
},
NewSessionForTab: async () => modernReplace("modern-new"),
ClearSessionForTab: async () => { modernReplace("modern-clear"); return { sessionPath: modernPath, sessionGeneration: 2 }; },
@@ -592,7 +594,7 @@ desktopStub.replaceCommands({
controller = undefined;
const modernRoot = createRoot(rootEl);
await act(async () => { modernRoot.render(<Probe />); await flushPromises(); });
await waitFor("modern startup snapshot", () => controller?.state.transcriptProtocol === 1);
await waitFor("modern startup snapshot", () => controller?.state.transcriptProtocol === 2);
const verifyModernSuffix = async (label: string) => {
await act(async () => {
desktopStub.emit("agent:event", { kind: "user_message", tabId: "tab-a", runtimeEpoch: modernEpoch,
@@ -612,12 +614,12 @@ await act(async () => { await controller?.clearSession(); await flushPromises();
eq(controller?.state.items.length, 0, "modern clear installs its empty cut");
await verifyModernSuffix("clear");
await act(async () => { await controller?.resumeSession("/sessions/modern-resume.jsonl", "tab-a")?.surfaceReady; await flushPromises(); });
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-snapshot", "modern resume records snapshot installation");
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern resume records snapshot installation");
eq(sessionPipelineDiagnostics().duplicateLoadCount, 0, "modern resume receives backend load evidence");
ok(typeof sessionPipelineDiagnostics().resumeSnapshotMs === "number", "modern resume measures snapshot time separately");
await verifyModernSuffix("resume");
await act(async () => { await controller?.openChannelSession("/sessions/modern-channel.jsonl", "tab-a")?.surfaceReady; await flushPromises(); });
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-snapshot", "modern channel records snapshot installation");
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern channel records snapshot installation");
await verifyModernSuffix("channel");
eq(modernAdoptions, 2, "resume and channel use adoption without a legacy history payload");
eq(modernSnapshots, 5, "each modern entry point obtains one authoritative cut");
@@ -631,18 +633,18 @@ eq(sessionPipelineDiagnostics().duplicateLoadCount, null, "pending switch does n
await act(async () => { await controller?.resumeSession("/sessions/fast.jsonl", "tab-a")?.surfaceReady; await flushPromises(); });
await act(async () => { slowModernGate.resolve(); await staleModern?.surfaceReady; await flushPromises(); });
eq(sessionPipelineDiagnostics().resumeSwitch?.totalMs, modernPhases.totalMs, "stale modern adoption cannot overwrite committed diagnostics");
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-snapshot", "modern race retains authoritative snapshot evidence");
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern race retains authoritative snapshot evidence");
desktopStub.commands.TranscriptSnapshotForTab = async () => { throw new Error("configured model is unavailable before controller startup"); };
desktopStub.commands.TranscriptFollowForTab = async () => { throw new Error("configured model is unavailable before controller startup"); };
desktopStub.commands.HistorySliceForTab = async (tabID: string, req: HistorySliceRequest) => { legacyReads++; return historySliceFromMessages(tabID, [{ role: "user", content: "recovered without controller" }], req); };
await act(async () => {
await controller?.retrySessionHistory("tab-a");
await flushPromises();
});
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "recovered without controller") ?? false,
"failed modern snapshot falls back to controller-independent history");
eq(legacyReads, 1, "snapshot failure performs one compatibility history read");
eq(controller?.state.hydrateError, undefined, "successful history fallback clears the recovery error");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "recovered without controller") ?? false),
"failed Follow never falls back to a different protocol");
eq(legacyReads, 0, "snapshot failure performs no compatibility history read");
ok(Boolean(controller?.state.hydrateError), "failed synchronization remains visible");
await act(async () => { modernRoot.unmount(); });
// ── session switch: one history commit, composer bound to the new runtime ────
// The switch shows the restored transcript as soon as its page lands, but the
@@ -683,16 +685,17 @@ desktopStub.replaceCommands({
switchHistoryPageCalls += 1;
return switchPage("full-history-refetch");
},
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => historySliceFromMessages(tabID, [], req),
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => historySliceFromMessages(tabID, switchMetaPath ? [{ role: "user", content: switchMetaPath }] : [], req),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
ReplayPendingPromptsForTab: async () => {},
ResumeSessionPageForTab: async (_tabID: string, path: string) => {
ResumeTranscriptSessionForTab: async (_tabID: string, path: string) => {
switchResumeCalls += 1;
if (path.includes("slow")) await slowSwitchGate.promise;
switchMetaPath = path;
const page = switchPage(path);
page.switch.totalMs = path.includes("slow") ? 500 : 4;
return page;
return page.switch;
},
});
@@ -725,7 +728,7 @@ eq(runtimeReadyForSubmit(controller?.state.meta), true, "composer re-enables onc
eq(switchResumeCalls, 1, "a switch issues exactly one resume page request");
eq(switchHistoryPageCalls, 0, "a switch does not refetch the full history page from the frontend");
eq(sessionPipelineDiagnostics().duplicateLoadCount, 0, "switch reports no duplicate durable load");
eq(sessionPipelineDiagnostics().resumeHistory?.source, "resume-loaded", "the switch's first screen is attributed to the resume page");
eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "the switch's first screen is attributed to Follow");
let slowNav: NavigationResult<void> | undefined;
let fastNav: NavigationResult<void> | undefined;
@@ -767,7 +770,7 @@ await act(async () => {
CheckpointsForTab: async () => checkpoints, HistoryCheckpointTurnsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
HistorySliceForTab: async (id: string, req: HistorySliceRequest) => historySliceFromMessages(id, [], req),
ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {},
ResumeSessionPageForTab: async (_id: string, target: string) => { path = target; return switchPage(target); },
ResumeTranscriptSessionForTab: async (_id: string, target: string) => { path = target; return switchPage(target).switch; },
StartTurnForTab: async () => { submissions.push(path); return { turnId: "wrong-source" }; },
});
const raceRoot = createRoot(document.createElement("div"));
@@ -550,9 +550,9 @@ eq(controller?.state.approval?.id, "plan-live", "a snapshot fetched before the p
eq(controller?.state.pendingPrompt, true, "the prompt gate survives the stale reconciliation");
eq(controller?.state.running, true, "the tab stays blocked on the user after the stale reconciliation");
// A snapshot fetched after the event still reconciles: if the backend truly
// has no pending prompt anymore, the zombie prompt is cleared.
// Only an ordered backend terminal event can release the prompt gate.
await act(async () => {
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a" });
await controller?.syncActiveTab(false);
await flushPromises();
});
@@ -586,10 +586,9 @@ eq(controller?.state.running, false, "fresh idle snapshot releases the blocked s
await flushPromises();
});
eq(controller?.state.approval?.id, "plan-zombie", "the stale idle snapshot is rejected, the prompt survives for now");
// The backend reports idle (the prompt was resolved); the scheduled fresh
// reconcile refetches that truth and clears the zombie, unlocking input.
// Metadata cannot clear a prompt. Its resolution arrives on Follow.
await act(async () => {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 300));
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a" });
await flushPromises();
});
eq(controller?.state.approval?.id, undefined, "the scheduled fresh reconcile clears the zombie the stale rejection preserved");
@@ -5,6 +5,7 @@ import type { AppBindings } from "../lib/bridge";
import type { TabMeta } from "../lib/types";
import type { RemoteSessionApi } from "../lib/useRemoteSession";
import { installDesktopHostStub } from "./desktopHostStub";
import { installRemoteTranscriptFixture } from "./helpers/remoteTranscriptFixture";
let passed = 0, failed = 0;
function ok(value: boolean, label: string) {
@@ -223,6 +224,7 @@ const desktopStub = installDesktopHostStub(({ main: { App: {
} as Partial<AppBindings> as AppBindings } }).main.App);
const __emitMockRemoteTab = (tabId: string, channel: "state" | "event", payload: unknown) => desktopStub.emit(`remote-tab:${tabId}:${channel}`, payload);
installRemoteTranscriptFixture(desktopStub.commands);
const [{ createRoot }, { RemoteSessionSurface }, { LocaleProvider }, { useRemoteSession }, { remoteRuntimeCommand }] = await Promise.all([
import("react-dom/client"),
import("../components/RemoteSessionSurface"),
@@ -290,7 +292,7 @@ ok(document.body.textContent?.includes("thinking hard") === true, "reasoning ren
});
ok(tape.filter((entry) => entry.startsWith("snapshot:")).length > snapshotsBefore, "a ready transition re-syncs the snapshot (session reset / reconnect path)");
}
ok(!document.body.textContent?.includes("streaming answer"), "the re-synced snapshot replaces the old session content")
ok(document.body.textContent?.includes("streaming answer") === true, "same-session reconnect restores the active prefix")
await act(async () => {
const statusBefore = tape.filter((entry) => entry === "status:tab-remote-1").length;
@@ -477,8 +479,8 @@ await act(async () => {
await act(async () => { __emitMockRemoteTab("tab-remote-1", "state", { state: "disconnected" }); await flush(); });
{
ok(!document.querySelector(".remote-surface--disconnected"), "live disconnected events do not render the placeholder");
ok(Boolean(document.querySelector(".session-recovery[role=status]")), "live disconnected events show connecting instead");
ok(tape.includes("setActive:tab-remote-1"), "live disconnected events trigger backend revival");
ok(document.body.textContent?.includes("retain this partial answer across disconnect") === true, "live disconnection preserves the transcript");
ok(!tape.includes("setActive:tab-remote-1"), "live disconnection does not automatically revive or resubmit work");
}
await act(async () => root.unmount());
@@ -517,14 +519,17 @@ ok(remoteRuntimeCommand("/branch experiment")?.rehydrate === true && remoteRunti
&& remoteRuntimeCommand("/compact preserve tests")?.method === "compact",
"session-changing management commands request authoritative rehydration");
statusPendingPrompt = true; replayedPrompts = [{ kind: "approval_request", approval: { id: "recovered-prompt", tool: "bash", subject: "replayed after drop" } }];
await act(async () => { await probe?.runManagementCommand("/context"); await flush(); });
ok(tape.includes("replay-prompts:tab-remote-2") && probe?.transcript.approval?.id === "recovered-prompt", "pending status recovers an SSE-dropped prompt through Serve");
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", replayedPrompts[0]);
await probe?.runManagementCommand("/context"); await flush();
});
ok(!tape.includes("replay-prompts:tab-remote-2") && probe?.transcript.approval?.id === "recovered-prompt", "Follow delivers pending prompts without legacy replay");
await act(async () => { await probe?.approve("recovered-prompt", "deny"); await flush(); }); statusPendingPrompt = false; replayedPrompts = [];
let remoteLiveNotifications = 0;
const unsubscribeRemoteLive = probe?.liveStore.subscribe("tab-remote-2", () => { remoteLiveNotifications += 1; });
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_started", turnStartedAt: 1234 });
__emitMockRemoteTab("tab-remote-2", "event", { kind: "text", text: "remote live ticker" });
__emitMockRemoteTab("tab-remote-2", "event", { kind: "text", messageId: "remote-answer", text: "remote live ticker" });
await flush();
});
ok(probe?.liveStore.getSnapshot("tab-remote-2")?.text === "remote live ticker" && remoteLiveNotifications > 0,
@@ -534,13 +539,14 @@ const turnDoneGeneration = probe?.surfaceGeneration;
statusGoalStatus = "complete";
snapshotHistory = [{ role: "user", content: "server-side prompt" }, { role: "assistant", content: "reconciled final answer" }];
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "message", messageId: "remote-answer", text: "reconciled final answer" });
__emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_done" });
await flush();
});
ok(probe?.composerProfile?.goalStatus === "complete" && probe.surfaceGeneration === turnDoneGeneration,
"turn_done refreshes goal status without replacing the transcript surface");
ok(probe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "reconciled final answer") === true,
"turn_done reconciles durable history after dropped Serve frames");
"committed result survives turn_done without a history rebase");
await act(async () => {
__emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-old", tool: "bash", subject: "old" } });
@@ -762,6 +768,10 @@ await act(async () => {
__emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" });
await flush();
});
await act(async () => {
__emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" });
await flush();
});
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true,
"ready-to-ready rotation hydrates the adopted session while old reconciliation is pending");
await act(async () => { __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" }); __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" }); await Promise.resolve(); });
@@ -772,7 +782,7 @@ await act(async () => {
});
await flush();
});
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh reconciled turn") === true
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true
&& !rotationProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "stale previous session"),
"session generation fence rejects stale history and hands reconciliation to the new generation");
await act(async () => rotationRoot.unmount());
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import React, { act, useLayoutEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { useRuntimeEventHandlers } from "../app-runtime/useRuntimeEventHandlers";
import { runtimeStateStore, type RuntimeProjection, type RuntimeSession } from "../lib/runtimeStateStore";
import { ToastProvider } from "../lib/toast";
import { LocaleProvider } from "../lib/i18n";
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
import { installDesktopHostStub } from "./desktopHostStub";
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
Object.assign(globalThis, { window: dom.window, document: dom.window.document,
localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true });
let chimes = 0;
// The sound suite verifies synthesis; this probe counts actual playback entries.
Object.assign(globalThis, { AudioContext: class { constructor() { chimes++; } } });
setAttentionPreference("synth");
setSuccessPreference("synth");
installDesktopHostStub({ ListTabs: async () => [] });
let handlers!: ReturnType<typeof useRuntimeEventHandlers>;
function Probe({ active = "B" }: { active?: string }) {
handlers = useRuntimeEventHandlers({ activeTabId: active, workspaceScopeKey: "fixture",
workspaceScopeActiveTabRef: useRef(active), userPlanModeByTabRef: useRef({}),
setTabMetas() {}, setTabOrderIds() {}, setComposerProfilesByTab() {}, setDockRefreshKey() {},
setProjectRevision() {}, setWorkspaceControllerEpoch() {}, async setControllerCollaborationMode() {} });
useLayoutEffect(() => {
// Layout precedes the notification module's passive loading effect.
handlers.handleRuntimeEvent({ kind: "turn_done", tabId: "B" });
}, []);
return <textarea aria-label="draft" defaultValue="B draft" />;
}
const root = createRoot(document.getElementById("root")!);
const paint = (active = "B") => act(async () => root.render(<LocaleProvider><ToastProvider><Probe active={active} /></ToastProvider></LocaleProvider>));
let revision = 0;
function session(kind = "ask", turnId = "turn-A", requestId = "1"): RuntimeSession {
return { tabId: "detached:A", scope: "project", workspaceRoot: "/fixture", topicId: "topic-A",
sessionId: "session-A", sessionPath: "", sessionGeneration: 1, open: false, remote: false, freshness: "synced",
state: { schemaVersion: 1, runtimeEpoch: "runtime-A", activityRevision: 1, revision: 1, phase: "executing",
running: true, turnId, turnStatus: "running", turnEventSeq: 1, pendingPrompt: true,
pendingInteractions: [{ requestId, kind, turnId, runtimeEpoch: "runtime-A", headId: "head-A" }],
cancelRequested: false, cancellable: true, backgroundJobs: 0, activity: "" } };
}
async function publish(sessions: RuntimeSession[]) {
const snapshot: RuntimeProjection = { epoch: "host", revision: ++revision, sessions,
topics: [{ scope: "project", workspaceRoot: "/fixture", node: { key: "topic-A", topicId: "topic-A", label: "Conversation A", kind: "session" } }] };
await act(async () => runtimeStateStore.commit(snapshot));
}
try {
await paint();
await act(async () => { await import("../lib/runtimeNotifications"); });
assert.equal(chimes, 1, "completion arriving before notification code loads is queued, not lost");
chimes = 0;
await act(async () => handlers.handleRuntimeEvent({ kind: "turn_done", err: "fixture error" }));
assert.equal(chimes, 0, "failed completion remains silent");
await publish([session()]);
assert.equal(chimes, 1, "detached A asks while B is being edited: sound must play before switching");
assert.match(document.querySelector('[role="status"]')!.textContent!, /Conversation A.*answer/i);
assert.equal((document.querySelector("textarea") as HTMLTextAreaElement).value, "B draft");
await publish([session()]);
await act(async () => handlers.handleRuntimeReady("A"));
await act(async () => handlers.handleRuntimeRebuilt("A", "new-view-epoch"));
await act(async () => handlers.handleRuntimeReady());
await paint("A");
await act(async () => handlers.handleRuntimeEvent({ kind: "ask_request", tabId: "A", turnId: "turn-A", ask: { id: "1", questions: [] } }));
assert.equal(chimes, 1, "reattach, ready and prompt replay must not ring again");
await paint("B");
await act(async () => handlers.handleRuntimeEvent({ kind: "approval_request", tabId: "A", turnId: "turn-approval", approval: { id: "1", tool: "bash", subject: "fixture" } }));
await publish([session("approval", "turn-approval")]);
assert.equal(chimes, 2, "wire first, snapshot second: approval rings once");
await publish([session("ask", "turn-new")]);
assert.equal(chimes, 3, "a new turn may reuse a per-controller request id");
await publish([{ ...session("ask", "turn-remote"), remote: true, tabId: "B", hostId: "remote" }]);
assert.equal(chimes, 4, "remote background session sharing the visible tab still notifies");
await publish([{ ...session("ask", "turn-unknown"), freshness: "unknown" }]);
assert.equal(chimes, 4, "untrusted stale remote state cannot create a new notification");
await publish([{ ...session("ask", "turn-unknown"), freshness: "synced" }]);
assert.equal(chimes, 5, "reconnection can recover a previously unseen pending question");
setAttentionPreference("off");
await publish([session("ask", "turn-muted")]);
assert.equal(chimes, 5, "muted audio stays muted");
assert.equal(document.querySelectorAll(".toast").length, 6, "visual alerts remain enabled with audio off");
setAttentionPreference("synth");
await publish([{ ...session("ask", "turn-visible"), tabId: "B", open: true }]);
assert.equal(chimes, 6, "visible requests retain their normal prompt sound");
assert.equal(document.querySelectorAll(".toast").length, 6, "visible prompt cards need no background toast");
for (const kind of ["plan", "recovery"]) {
await publish([session(kind, `turn-${kind}`)]);
await act(async () => handlers.handleRuntimeEvent({ kind: "approval_request", tabId: "A", turnId: `turn-${kind}`,
approval: { id: "1", tool: "fixture", subject: "fixture", kind } }));
}
assert.equal(chimes, 8, "plan and recovery approvals share request/snapshot dedupe");
await act(async () => root.unmount());
await publish([session("ask", "turn-disposed")]);
assert.equal(chimes, 8, "unmount removes the global state subscription");
console.log("runtime attention: background Ask, approval, replay, navigation, remote, reconnect, mute and disposal passed");
} finally { dom.window.close(); }
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
import { runtimeStateStore, type RuntimeSession } from "../lib/runtimeStateStore";
import { t } from "../lib/i18n";
let alerts = 0;
let revision = 0;
const owner = createRuntimeNotifications(() => ({ activeTabId: "B", t, showToast() { alerts++; } }));
function session(index: number): RuntimeSession {
return { tabId: `detached:${index}`, scope: "global", workspaceRoot: "", topicId: "", sessionPath: "",
sessionGeneration: 1, open: false, remote: false, freshness: "synced",
state: { schemaVersion: 1, runtimeEpoch: `runtime:${index}`, activityRevision: 1, revision: 1,
phase: "executing", running: true, turnId: `turn:${index}`, turnStatus: "in_progress", turnEventSeq: 1,
pendingPrompt: true, pendingInteractions: [{ requestId: "1", kind: "ask", turnId: `turn:${index}`,
runtimeEpoch: `runtime:${index}`, headId: "head" }],
cancelRequested: false, cancellable: true, backgroundJobs: 0, activity: "" } };
}
function publish(sessions: RuntimeSession[]) {
runtimeStateStore.commit({ epoch: "fixture", revision: ++revision, topics: [], sessions });
}
publish([]);
owner.start();
try {
publish([session(0)]);
assert.equal(alerts, 1);
for (let index = 1; index <= 600; index++) {
owner.accept({ event: { kind: "approval_request", tabId: "C", turnId: `other:${index}`, approval: { id: "1" } } });
}
assert.equal(alerts, 601);
publish([session(0)]);
assert.equal(alerts, 601, "history eviction must not re-alert a still-pending background question");
const manyPending = Array.from({ length: 600 }, (_, index) => session(index + 1000));
publish(manyPending);
assert.equal(alerts, 1201);
publish(manyPending);
assert.equal(alerts, 1201, "more pending requests than the history cap cannot create an alert storm");
publish(manyPending.map(item => ({ ...item, freshness: "unknown" })));
publish(manyPending);
assert.equal(alerts, 1201, "disconnect and reconnect retain known pending identities beyond the history cap");
owner.accept({ event: { kind: "ask_request", tabId: "reattached", turnId: "turn:1000", ask: { id: "1" } } });
assert.equal(alerts, 1201, "wire replay also respects retained pending identities");
publish([]);
publish([session(9000)]);
assert.equal(alerts, 1202, "a new pending request still alerts after the old requests resolve");
console.log("runtime notifications: pending identities survive bounded history eviction and repeated large snapshots");
} finally { owner.dispose(); }
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { sessionIdentityKey } from "../app-runtime/sessionTarget";
import { sameSessionIdentity, sessionIdentityStableKey } from "../lib/sessionIdentity";
const a = { session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "", sessionGeneration: 1 };
const b = { session: { hostId: "local", sessionId: "canonical-b" }, sessionPath: "", sessionGeneration: 1 };
assert.notEqual(sessionIdentityStableKey(a), sessionIdentityStableKey(b), "empty-path canonical sessions own different cache keys");
assert.equal(sameSessionIdentity(a, { ...a }), true, "the same SessionRef is stable across snapshots");
assert.equal(sameSessionIdentity(a, b), false, "SessionRef prevents canonical transcript cross-wiring");
assert.equal(
sessionIdentityKey({ ...a, tabId: "reused-tab", scope: "project", workspaceRoot: "/repo", topicId: "legacy-topic" }),
sessionIdentityStableKey(a),
"canonical SessionRef outranks reused tab, topic and compatibility-path identities",
);
console.log("session identity: canonical SessionRef owns caches, navigation and hydration fences");
@@ -40,15 +40,16 @@ assert.equal(hydrated.reasoning, "complete reasoning");
const dom = new JSDOM("", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
const stub = installDesktopHostStub({ SessionOpenForTab: async () => ({
...view, recent: { ...view.recent, entries: [{ ...view.recent.entries[0], position: 50, visibleTurn: 4 }] },
const stub = installDesktopHostStub({ SessionHistoryWindowForTab: async () => ({
messages: [{ ...view.recent.entries[0], position: 50, visibleTurn: 4 }], status: "ready", snapshotSequence: 0,
coverageSequence: 0, generation: "mock", totalTurns: 4, hasOlder: true, hasNewer: false, olderCursor: "older",
}) });
const limited = await canonicalHistorySlice("tab-1", { cursor: "" });
assert.equal(limited.entries.length, 1);
assert.equal(limited.hasOlder, true, "a byte-limited recent window still exposes older history");
assert.ok(limited.nextCursor);
assert.equal(limited.revisionKnown, false, "zero sequence does not invent a durable fingerprint");
stub.commands.SessionOpenForTab = async () => ({ ...view, storageGeneration: "", recent: { ...view.recent, storageGeneration: "", entries: [] } });
stub.commands.SessionHistoryWindowForTab = async () => ({ messages: [], status: "ready", snapshotSequence: 0, coverageSequence: 0, totalTurns: 0, hasOlder: false, hasNewer: false });
const empty = await canonicalHistorySlice("tab-1", { cursor: "" });
assert.equal(empty.entries.length, 0, "an empty recent snapshot does not require a locator read");
stub.uninstall();
@@ -85,7 +85,7 @@ try {
await act(async () => root.render(<LocaleProvider><ChatPaneRegion transitioning={false} t={((key: string) => key) as Translator}
imDetail={null} transcript={{ state: failedLocal, items: [], tabId: "local", geometrySessionKey: "local", footerHeight: 140,
transcriptHydrating: false, navigationDataReady: true, readOnly: false, controllerReady: true, hydratePlaceholderActive: false,
clearContextPending: false, creation: false, availability: projectSessionAvailability({ local: failedLocal }),
clearContextPending: false, availability: projectSessionAvailability({ local: failedLocal }),
rewind: { stateActive: false, committing: false, signal: undefined }, revealSignal: 0, invocationMetadata: undefined,
surfaceCommitToken: undefined, liveStore: undefined }} onRetryHistory={async () => { retries++; }}
commands={{ onPrompt: noop, onFork: noop, onLoadOlderHistory: async () => false, onSurfacePaintReady: noop }} /></LocaleProvider>));
@@ -23,7 +23,7 @@ function ok(condition: boolean, label: string) {
console.log("\nsettings navigation contract");
ok(panel.includes('["workbench", "creation"] as const'), "desktop settings expose only workbench and creation");
ok(!panel.includes("settings.desktopLayoutStyle"), "desktop settings no longer offer a layout-style switch");
ok(!panel.includes('["workbench", "classic", "creation"] as const'), "desktop settings no longer offer classic");
ok(/useEffect\(\(\) => \{[\s\S]*?content\.scrollTop = 0;[\s\S]*?content\.scrollLeft = 0;[\s\S]*?\}, \[tab\]\);/.test(panel), "switching settings pages resets both content scroll axes");
ok(navigation.includes('aria-current={activeTab === id ? "page" : undefined}'), "the active settings page is exposed semantically");
@@ -123,7 +123,7 @@ await act(async () => {
const generalFieldLabels = Array.from(rootEl.querySelectorAll(".settings-section__body > .settings-field .settings-field__label"))
.map((label) => label.textContent?.trim());
eq(generalFieldLabels[0], "Desktop style", "general settings place desktop style first");
eq(generalFieldLabels[0], "Language", "general settings place language first");
eq(document.querySelectorAll(".step-limit-control").length, 0, "general settings hide executor and planner step-limit controls");
ok(!rootEl.textContent?.includes("Session experience"), "general settings remove the retired session experience field");
ok(!rootEl.textContent?.includes("Conversation density"), "general settings do not render the retired density field");
@@ -388,7 +388,7 @@ await act(async () => {
retryButton.click();
await flushPromises();
});
await waitFor("settings retry success", () => document.body.textContent?.includes("Desktop style") === true);
await waitFor("settings retry success", () => document.body.textContent?.includes("Desktop & language") === true);
eq(failingSettingsCalls, 2, "settings retry calls Settings again");
ok(document.body.textContent?.includes("Settings could not be loaded.") === false, "settings retry clears the load error");
@@ -9,6 +9,7 @@ import { verifyDeferredHistoryCloseRace, verifyStaleHistoryFingerprint } from ".
import { historySliceFromMessages } from "./mockHistorySlice";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySlice, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
import { installDesktopHostStub } from "./desktopHostStub";
import { verifyExplicitTranscriptRetry } from "./helpers/explicitTranscriptRetry";
let passed = 0;
let failed = 0;
@@ -24,11 +25,7 @@ function ok(value: boolean, label: string) {
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
ok(actual === expected, actual === expected ? label : `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushPromises(): Promise<void> {
@@ -166,6 +163,8 @@ const staleForkReassertGGate = deferred<void>();
const historyCalls: string[] = [];
let historyLCalls = 0;
let historyMCalls = 0;
const contextMGate = deferred<ContextInfo>();
let contextMCalls = 0;
let historyNCalls = 0;
let startTabNDuringMeta = false;
const cancelCalls: string[] = [];
@@ -217,6 +216,7 @@ const appStubTable = ({
return metaFor(tabsById.get(tabID) ?? tabA);
},
ContextUsageForTab: async (tabID: string) => {
if (tabID === "tab-m" && ++contextMCalls === 1) return contextMGate.promise;
if (tabID === "tab-d" && holdNextContextForD) {
contextDCalls += 1;
holdNextContextForD = false;
@@ -233,7 +233,7 @@ const appStubTable = ({
HistoryForTab: async (tabID: string) => {
historyCalls.push(tabID);
if (tabID === "tab-o") { failSetActiveFor = "tab-a"; throw new Error("history failed at /private/session.jsonl"); }
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-b") return newSessionTargets.includes(tabID) ? [] : historyB.promise;
if (tabID === "tab-d") return historyD.promise;
if (tabID === "tab-e") return [userMessage("fork E")];
if (tabID === "tab-g") return [userMessage("history G")];
@@ -466,7 +466,7 @@ await act(async () => {
await flushPromises();
});
await waitFor("tab-a restored", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips history hydration when reselected");
eq(historyCalls.length, historyCallsBeforeReturnToA + 1, "reselected tab installs one consistent Follow baseline");
await act(async () => {
historyB.resolve([userMessage("late B")]);
@@ -489,10 +489,10 @@ await act(async () => {
await flushPromises();
});
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "new session does not restore the previous binding's late history");
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
eq(controller?.state.approval?.id, "stale-fallback-approval", "metadata cannot remove a backend pending approval");
eq(controller?.state.running, true, "pending approval remains active until backend resolution");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
@@ -683,9 +683,9 @@ await act(async () => {
});
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
eq(historyCalls.length, historyCallsBeforeReopenD + 2, "topic navigation installs a consistent baseline for each binding");
eq(controller?.state.approval?.id, "stale-approval", "reopening preserves backend pending approval");
eq(controller?.state.running, true, "reopening preserves the pending runtime state");
await act(async () => {
await controller?.rewind(0, "fork");
@@ -867,7 +867,7 @@ await act(async () => {
await flushPromises();
});
await waitFor("matching fingerprint reuses tab history", () => controller?.activeTabId === "tab-k");
eq(historyCalls.length, historyCallsAfterFingerprintRefresh, "matching fingerprint reuses the hydrated transcript after navigation");
eq(historyCalls.length, historyCallsAfterFingerprintRefresh + 1, "navigation uses Follow rather than an unrelated metadata fingerprint");
// An older response with a stale fingerprint must not prepend into the newer page.
await act(async () => {
@@ -896,15 +896,15 @@ await verifyDeferredHistoryCloseRace({
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
});
// Transcript and sidecar reads must reconcile when a save advances between them.
// An explicit retry needs a new cut even while the old cut's ancillary read is pending.
await act(async () => {
await controller?.openProjectTab(tabM.workspaceRoot, tabM.topicId || "");
await flushPromises();
});
await waitFor("split transcript/meta read reconciles", () => controller?.activeTabId === "tab-m"
&& (controller.state.items.some((item) => item.kind === "user" && item.text === "history M v2") ?? false));
eq(historyMCalls, 2, "mismatched page and metadata trigger one bounded history reload");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale M v1") ?? false), "reconciled hydration does not retain the stale page");
await verifyExplicitTranscriptRetry({
controller: () => controller, historyCalls: () => historyMCalls, ancillaryCalls: () => contextMCalls,
releaseAncillary: () => contextMGate.resolve({ ...context, used: 99999 }), waitFor, flush: flushPromises, ok,
});
// A live turn that starts between durable page and metadata reads owns the transcript.
startTabNDuringMeta = true;
@@ -407,7 +407,7 @@ await act(async () => {
});
await flushPromises();
});
eq(controller?.state.ask?.id, "ask-tab-ask", "Ask is visible before activation history hydrates");
eq(controller?.state.ask?.id, undefined, "Ask waits for the consistent activation snapshot");
await act(async () => {
// Production emits agent:ready before topic:activation ready. Hold the
// startup hydration open so activation-ready must supersede it without
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Change, FollowRequest, Snapshot, TranscriptFollowResponse } from "../generated/desktopContract.generated";
import { TranscriptFollowClient, type FollowConsumer } from "../lib/transcriptFollowClient";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
}
async function microtasks() { for (let i = 0; i < 16; i++) await Promise.resolve(); }
function baseline(subscription = "subscription", overrides: Partial<Snapshot> = {}): TranscriptFollowResponse {
return { protocolVersion: 2, subscription, changes: [], resetRequired: false, snapshot: {
protocolVersion: 1, snapshotId: "cut", identity: { sessionId: "session", headId: "", runtimeEpoch: "epoch", rewriteEpoch: 0 },
projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4,
records: [{ id: "m:answer", order: 0, message: { role: "assistant", messageId: "answer", content: "prefix" }, refs: [] }],
activeRecords: [], activeAttempts: [], runtime: { status: "in_progress", pendingEvents: [], samplingCount: 0, toolCount: 0 },
before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false, ...overrides,
} };
}
function suffix(changes: Change[], resetRequired = false): TranscriptFollowResponse {
return { protocolVersion: 2, subscription: "subscription", changes, resetRequired };
}
function frame(revision: number, text: string): Change {
return { revision, commitSeq: 4, durableSeq: 4, index: 0, event: { kind: "text", messageId: "answer", text } } as Change;
}
function transport() {
const requests: FollowRequest[] = [];
const pending: ReturnType<typeof deferred<TranscriptFollowResponse>>[] = [];
const read = (request: FollowRequest) => {
requests.push(request);
if (request.close) return Promise.resolve(suffix([]));
const next = deferred<TranscriptFollowResponse>(); pending.push(next); return next.promise;
};
return { requests, pending, client: new TranscriptFollowClient(read) };
}
function consumer(): FollowConsumer & { text: string; installed: number; delivered: Change[]; states: string[] } {
return {
text: "", installed: 0, delivered: [], states: [],
install(response) { this.installed++; this.text = response.snapshot!.records[0]?.message.content ?? ""; },
changes(changes) { this.delivered.push(...changes); for (const change of changes) this.text += change.event?.text ?? ""; },
connection(state) { this.states.push(state); },
};
}
test("follow installs the complete baseline before asking for its suffix", async () => {
const io = transport(); const view = consumer(); const installed = deferred<void>();
const original = view.install.bind(view);
view.install = async response => { await installed.promise; await original(response); };
const starting = io.client.start(view);
io.pending.shift()!.resolve(baseline());
await microtasks();
assert.equal(io.requests.length, 1, "suffix must wait until installation commits");
installed.resolve(); await starting;
assert.equal(view.text, "prefix");
assert.deepEqual(io.requests[1], { subscription: "subscription", afterRevision: 10 });
io.pending.shift()!.resolve(suffix([frame(11, " suffix")])); await microtasks();
assert.equal(view.text, "prefix suffix"); io.client.stop();
});
test("follow acknowledges display revisions independently and ignores duplicate frames", async () => {
const io = transport(); const view = consumer(); const starting = io.client.start(view);
io.pending.shift()!.resolve(baseline()); await starting;
io.pending.shift()!.resolve(suffix([frame(11, " first"), frame(11, " first"), frame(12, " second")]));
await microtasks();
assert.equal(view.text, "prefix first second"); assert.equal(view.delivered.length, 2);
assert.deepEqual(io.requests[io.requests.length - 1], { subscription: "subscription", afterRevision: 12 });
io.pending.shift()!.resolve(suffix([{ revision: 13, firstSeq: 5, commitSeq: 8, durableSeq: 4, index: 0 }]));
await microtasks(); assert.equal(view.delivered[view.delivered.length - 1]?.commitSeq, 8); io.client.stop();
});
test("settlement pairs with the stable attempt and commit despite delivery order and duplication", async () => {
const io = transport(); const view = consumer(); const starting = io.client.start(view);
io.pending.shift()!.resolve(baseline("subscription", { activeAttempts: [{ id: "attempt", messageId: "answer", turnId: "turn", nextIndex: 1 }] }));
await starting;
const commit: Change = { revision: 11, firstSeq: 5, commitSeq: 5, durableSeq: 4, index: 0, records: [{ messageId: "answer", role: "assistant", content: "complete" }] };
const end: Change = { revision: 12, commitSeq: 5, durableSeq: 5, index: 1, attemptId: "attempt", resultSeq: 5, resultKind: "message/complete",
event: { kind: "stream_attempt", messageId: "answer", streamAttempt: { id: "attempt", action: "commit" } } } as Change;
io.pending.shift()!.resolve(suffix([end, commit, end])); await microtasks();
assert.deepEqual(view.delivered.map(change => change.revision), [11, 12]);
assert.equal(view.states[view.states.length - 1], "connected"); io.client.stop();
});
for (const failure of ["overflow", "revision gap", "business gap", "sampling gap", "settlement mismatch"] as const) {
test(`follow ${failure} preserves visible content and only requests a new baseline`, async t => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const io = transport(); const view = consumer(); const starting = io.client.start(view);
io.pending.shift()!.resolve(baseline()); await starting;
const broken = failure === "overflow" ? suffix([], true)
: failure === "revision gap" ? suffix([frame(12, "bad")])
: failure === "business gap" ? suffix([{ revision: 11, firstSeq: 6, commitSeq: 6, durableSeq: 4, index: 0 }])
: failure === "settlement mismatch" ? suffix([{ revision: 11, commitSeq: 4, durableSeq: 4, index: 0, attemptId: "unknown", resultSeq: 4, resultKind: "message/complete", event: { kind: "stream_attempt", messageId: "different", streamAttempt: { id: "unknown", action: "commit" } } } as Change])
: suffix([{ ...frame(11, "bad"), attemptId: "missing-attempt", index: 3 }]);
io.pending.shift()!.resolve(broken); await microtasks();
assert.equal(view.text, "prefix"); assert.equal(view.delivered.length, 0);
assert.equal(view.states[view.states.length - 1], "disconnected");
assert.ok(io.requests.some(request => request.close && request.subscription === "subscription"));
t.mock.timers.tick(1000); await microtasks();
assert.deepEqual(io.requests[io.requests.length - 1], {}, "recovery issues a read, never a model submission");
assert.equal(view.text, "prefix", "content remains visible while replacement is pending");
io.pending.shift()!.resolve(baseline("replacement", { projectionRevision: 20, coveredThroughSeq: 8 }));
await microtasks(); assert.equal(view.installed, 2); assert.equal(view.states[view.states.length - 1], "connected");
assert.ok(io.requests.every(request => Object.keys(request).every(key => ["subscription", "afterRevision", "close"].includes(key))));
io.client.stop();
});
}
test("stopped generations ignore delayed suffixes and close their subscription", async () => {
const io = transport(); const oldView = consumer(); const starting = io.client.start(oldView);
io.pending.shift()!.resolve(baseline()); await starting;
const oldSuffix = io.pending.shift()!; io.client.stop();
const newView = consumer(); const restarting = io.client.start(newView);
io.pending.shift()!.resolve(baseline("next", { identity: { sessionId: "next", headId: "", runtimeEpoch: "epoch-next", rewriteEpoch: 0 } }));
await restarting;
oldSuffix.resolve(suffix([frame(11, " stale")])); await microtasks();
assert.equal(oldView.text, "prefix"); assert.equal(newView.text, "prefix");
assert.ok(io.requests.some(request => request.close && request.subscription === "subscription")); io.client.stop();
});
test("stopping before baseline arrives closes the late subscription without installation", async () => {
const io = transport(); const view = consumer(); const starting = io.client.start(view);
const late = io.pending.shift()!; io.client.stop(); late.resolve(baseline()); await starting; await microtasks();
assert.equal(view.installed, 0);
assert.ok(io.requests.some(request => request.close && request.subscription === "subscription"));
});
test("stopping during asynchronous installation releases the newly opened subscription", async () => {
const io = transport(); const installed = deferred<void>(); const view = consumer();
view.install = async () => installed.promise;
const starting = io.client.start(view); io.pending.shift()!.resolve(baseline()); await microtasks();
io.client.stop(); installed.resolve(); await starting; await microtasks();
assert.ok(io.requests.some(request => request.close && request.subscription === "subscription"), "cancelled install leaked its subscription");
assert.ok(!view.states.includes("connected"));
});
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
import { installDesktopHostStub } from "./desktopHostStub";
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
const commands: Record<string, unknown> = {};
installDesktopHostStub(commands);
const [{ TranscriptSessionFollower }, { initialState, reducer }, { getTranscriptStore }] = await Promise.all([
import("../lib/transcriptSessionFollower"), import("../lib/useController"), import("../lib/transcriptStore"),
]);
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
}
test("expired settled content requests the owning follower to resynchronize", async () => {
const { canonicalHistoryContent, registerTranscriptContentRecovery } = await import("../lib/canonicalTranscriptBackend");
let recoveries = 0;
commands.TranscriptContentForTab = async () => ({ stale: true });
const release = registerTranscriptContentRecovery("expired-content", () => { recoveries++; });
const ref = { entryId: "m:answer", field: "content", size: 1, chunks: 1, revision: 1, digest: "expired",
transcriptRef: { snapshotId: "expired", recordId: "m:answer", path: ["content"], bytes: 1 } };
await assert.rejects(canonicalHistoryContent("expired-content", ref, 0), /synchronizing/);
assert.equal(recoveries, 1);
release();
await assert.rejects(canonicalHistoryContent("expired-content", ref, 0), /synchronizing/);
assert.equal(recoveries, 1, "released owner cannot be restarted by a late content result");
const delayed = deferred<{ stale: boolean }>();
commands.TranscriptContentForTab = () => delayed.promise;
const oldRelease = registerTranscriptContentRecovery("expired-content", () => { recoveries++; });
const staleRead = canonicalHistoryContent("expired-content", ref, 0);
oldRelease();
const newRelease = registerTranscriptContentRecovery("expired-content", () => { recoveries += 100; });
delayed.resolve({ stale: true });
await assert.rejects(staleRead, /synchronizing/);
assert.equal(recoveries, 1, "late stale reference cannot resynchronize a replacement session");
newRelease();
});
test("reading old pages isolates live output and rejoins the current stable node", () => {
let state: import("../lib/useController").State = { ...initialState, transcriptProtocol: 2, historyHasNewer: true,
items: [{ kind: "user" as const, id: "old-reader", text: "old page" }] };
const resident = state.items;
state = reducer(state, { type: "event", e: { kind: "text", messageId: "active", text: "full prefix" } });
assert.equal(state.items, resident);
assert.equal(state.offscreenItems?.find(item => item.id === "m:active")?.kind, "assistant");
const active = state.offscreenItems!.find(item => item.id === "m:active")!;
state = reducer(state, { type: "history_append", items: [], startTurn: 0, endTurn: 1, totalTurns: 1, hasOlder: true, hasNewer: false });
assert.equal(state.items.find(item => item.id === "m:active"), active);
assert.equal(state.offscreenItems, undefined);
});
test("business replacement preserves authoritative final turn annotations", () => {
const final = { kind: "assistant" as const, id: "m:final", text: "answer", reasoning: "", streaming: false, turnFinal: true, turnDurationMs: 933524, samplingCount: 72, toolCount: 72 };
const state = reducer({ ...initialState, items: [final] }, { type: "transcript_records", projection: {
items: [{ ...final, turnFinal: undefined, turnDurationMs: undefined, samplingCount: undefined, toolCount: undefined }],
removeIds: [], startTurn: 0, endTurn: 1, totalTurns: 1, hasOlder: false, hasNewer: false, revision: 2, revisionKnown: true, digest: "cut",
} });
assert.equal(state.items[0].kind === "assistant" && state.items[0].turnDurationMs, 933524);
});
async function microtasks() { for (let i = 0; i < 16; i++) await Promise.resolve(); }
function initial(subscription: string): TranscriptFollowResponse {
return {
protocolVersion: 2, subscription, changes: [], resetRequired: false,
snapshot: {
protocolVersion: 1, snapshotId: "cut", identity: { sessionId: subscription, runtimeEpoch: "epoch", rewriteEpoch: 0, headId: "" },
projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4, records: [], activeRecords: [], activeAttempts: [],
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false,
},
history: {
status: "ready", snapshotSequence: 4, coverageSequence: 4, generation: "generation", totalTurns: 1, hasOlder: false, hasNewer: false,
messages: [{ messageId: "answer", position: 0, version: 1, role: "assistant", eventSequence: 4, visibleTurn: 1,
preview: "", contentRef: { digest: "canonical-message", bytes: 8192 } }],
},
};
}
for (const remote of [false, true]) {
test(`${remote ? "remote" : "local"} follower retains an empty canonical body reference as a loadable assistant node`, async () => {
const tab = remote ? "follow-ref-remote" : "follow-ref-local";
let state = initialState;
const requests: FollowRequest[] = [];
const poll = deferred<TranscriptFollowResponse>();
const read = async (tabId: string, request: FollowRequest): Promise<TranscriptFollowResponse> => {
assert.equal(tabId, tab); requests.push(request);
if (request.close) return { protocolVersion: 2, subscription: tab, changes: [], resetRequired: false };
if (!request.subscription) return initial(tab);
return poll.promise;
};
const key = remote ? "RemoteTranscriptFollowForTab" : "TranscriptFollowForTab";
const wrong = remote ? "TranscriptFollowForTab" : "RemoteTranscriptFollowForTab";
commands[key] = read;
commands[wrong] = () => { throw new Error("cross-host fallback is forbidden"); };
commands.SendForTab = () => { throw new Error("history recovery must not invoke the model"); };
commands.RemoteSendForTab = commands.SendForTab;
const follower = new TranscriptSessionFollower(tab, `/session/${tab}`, remote, action => { state = reducer(state, action); });
try {
await follower.start();
const assistant = state.items.find(item => item.kind === "assistant");
assert.ok(assistant, "empty inline preview with a canonical ref must not disappear");
assert.equal(assistant.id, "m:answer");
assert.equal(getTranscriptStore().hasContentReference(tab, "m:answer", "content"), true);
assert.equal(state.running, false);
assert.equal(state.transcriptProtocol, 2);
assert.equal(requests.length, 2, "follow starts only after initial installation");
} finally { follower.stop(); }
await microtasks();
assert.ok(requests.some(request => request.close && request.subscription === tab));
});
}
test("stopped session follower cannot install a delayed baseline into its replaced tab", async () => {
const delayed = deferred<TranscriptFollowResponse>();
const requests: FollowRequest[] = [];
commands.TranscriptFollowForTab = (_tab: string, request: FollowRequest) => {
requests.push(request);
return request.close ? Promise.resolve({ protocolVersion: 2, subscription: "stale", changes: [], resetRequired: false }) : delayed.promise;
};
let state = initialState;
const follower = new TranscriptSessionFollower("stale-tab", "/session/stale", false, action => { state = reducer(state, action); });
const loading = follower.start(); follower.stop(); delayed.resolve(initial("stale")); await loading; await microtasks();
assert.equal(state.items.length, 0);
assert.ok(requests.some(request => request.close && request.subscription === "stale"));
});
for (const remote of [false, true]) test(`${remote ? "remote" : "local"} active reference is complete before suffix polling`, async () => {
const response = initial(`prefix-${remote}`);
response.snapshot!.runtime.status = "in_progress";
response.snapshot!.activeAttempts = [{ id: "attempt", messageId: "answer", turnId: "turn", nextIndex: 1 }];
response.snapshot!.records = [{ id: "m:answer", order: 0, message: { role: "assistant", messageId: "answer", content: "truncated" },
refs: [{ snapshotId: "cut", recordId: "m:answer", path: ["content"], bytes: 20 }] }];
const content = deferred<{ data: string; nextOffset: number; done: boolean; stale: boolean }>();
const poll = deferred<TranscriptFollowResponse>();
let polls = 0;
commands[remote ? "RemoteTranscriptContentForTab" : "TranscriptContentForTab"] = () => content.promise;
commands[remote ? "RemoteTranscriptFollowForTab" : "TranscriptFollowForTab"] = (_tab: string, request: FollowRequest) => {
if (request.close) return Promise.resolve({ protocolVersion: 2, changes: [], resetRequired: false, subscription: response.subscription });
if (!request.subscription) return Promise.resolve(response);
polls++; return poll.promise;
};
let state = initialState;
const follower = new TranscriptSessionFollower(`prefix-${remote}`, "", remote, action => { state = reducer(state, action); });
const starting = follower.start();
await microtasks();
assert.equal(polls, 0);
assert.equal(state.items.length, 0, "partial baseline is not published");
content.resolve({ data: "complete prefix", nextOffset: 15, done: true, stale: false });
await starting;
assert.equal(state.live?.text, "complete prefix");
assert.equal(polls, 1);
follower.stop();
});
test("v2 keeps deferred bodies through subsequent samples and attaches terminal time by backend message identity", () => {
let state: import("../lib/useController").State = { ...initialState, transcriptProtocol: 2, running: true, activeTurnId: "turn",
items: [
{ kind: "assistant" as const, id: "m:deferred", text: "", reasoning: "", streaming: false },
{ kind: "assistant" as const, id: "m:final", text: "answer", reasoning: "thinking", streaming: false },
] };
state = reducer(state, { type: "event", e: { kind: "text", messageId: "next", text: "later sample" } });
assert.ok(state.items.some(item => item.id === "m:deferred"), "text frames must not remove unloaded body owners");
state = reducer(state, { type: "event", e: { kind: "turn_done", turnId: "turn" } });
state = reducer(state, { type: "transcript_runtime", runtime: { turnId: "turn", status: "completed",
finalMessageId: "final", durationMs: 933524, samplingCount: 72, toolCount: 72, pendingEvents: [] } });
const final = state.items.find(item => item.id === "m:final");
assert.ok(final?.kind === "assistant");
assert.equal(final.turnDurationMs, 933524);
assert.equal(final.turnFinal, true);
assert.ok(state.items.some(item => item.id === "m:deferred"), "terminal must retain deferred body owners");
const later = state.items.find(item => item.id === "m:next");
assert.ok(later?.kind === "assistant");
assert.equal(later.turnDurationMs, undefined, "array-tail sample is not the final reply");
});
@@ -1,287 +0,0 @@
import assert from "node:assert/strict";
import type { TranscriptSnapshot } from "../lib/transcriptProtocol";
import type { SnapshotTransport } from "../lib/transcriptSnapshotClient";
import { installDesktopHostStub } from "./desktopHostStub";
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
installDesktopHostStub({});
const [{ TurnEventProjector }, { TranscriptSnapshotClient, resolveSnapshotItems, resolveSnapshotTool, rebaseSnapshotContentPatches }, { initialState, reducer, historyMessagesToItems }] = await Promise.all([
import("../lib/turnEventProjection"), import("../lib/transcriptSnapshotClient"), import("../lib/useController"),
]);
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
function cut(overrides: Partial<TranscriptSnapshot> = {}): TranscriptSnapshot {
return { protocolVersion: 1, snapshotId: "cut-1", identity: { sessionId: "session", headId: "head", rewriteEpoch: 0, runtimeEpoch: "epoch" },
projectionRevision: 4, coveredThroughSeq: 4, records: [
{ id: "m:user", order: 0, message: { role: "user", messageId: "user", submissionId: "submit", content: "same question", historyTurn: 1 }, refs: [] },
{ id: "m:assistant", order: 1, message: { role: "assistant", messageId: "assistant", content: "prefix", pending: true }, refs: [] },
], activeRecords: [], runtime: { turnId: "turn", submissionId: "submit", status: "in_progress", pendingEvents: [] },
activeAttempts: [{ id: "attempt", messageId: "assistant" }], before: 0, hasOlder: false, totalRecords: 2, totalTurns: 1, stale: false, ...overrides };
}
const quietTransport = { replay: async () => ({ events: [], floorSeq: 1, latestSeq: 4, nextAfterSeq: 4, hasMore: false, resetRequired: false, runtimeEpoch: "epoch" }) };
const transport: SnapshotTransport = { snapshot: async () => cut(), page: async () => cut(), content: async () => ({ data: "", nextOffset: 0, done: true, stale: false }) };
// A temporary load gap and a permanent release are distinct lifecycle events.
// Outline owners use this distinction to keep their retry binding after a
// failed refresh while still releasing it when the tab really goes away.
{
const changes: string[] = [];
let attempts = 0;
const projector = new TurnEventProjector(quietTransport);
projector.bind(() => {});
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => {
attempts += 1;
if (attempts === 1) throw new Error("network down");
return cut();
} }, projector, undefined, (_tabId, snapshotId, change) => changes.push(`${change}:${snapshotId ?? ""}`));
await assert.rejects(client.load("tab", () => {}), /network down/);
assert.deepEqual(changes, ["loading:"], "a failed refresh only announces its temporary gap");
await client.load("tab", () => {});
client.release("tab");
assert.deepEqual(changes, ["loading:", "loading:", "installed:cut-1", "released:"],
"a later retry installs normally and real release remains explicit");
}
// Hydration suspends the live suffix, installs a full prefix, then advances the
// cursor only after reducer commits. Equal user text is never the correlation key.
{
let state = reducer(initialState, { type: "user", text: "same question", seq: 0, submissionId: "submit" });
const userID = state.items[0].id;
const fetched = deferred<TranscriptSnapshot>();
const projector = new TurnEventProjector(quietTransport);
const client = new TranscriptSnapshotClient({ ...transport, snapshot: () => fetched.promise }, projector);
projector.bind((event) => { client.observeEvent("tab", event); state = reducer(state, { type: "event", e: event }); });
const loading = client.load("tab", (snapshot) => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
projector.receiveLive("tab", { kind: "text", seq: 5, runtimeEpoch: "epoch", sessionId: "session", messageId: "assistant", text: " suffix" });
fetched.resolve(cut());
assert.equal(await loading, true);
for (let i = 0; i < 30; i++) await Promise.resolve();
assert.equal(state.live?.text, "prefix suffix");
assert.equal(state.items.filter((item) => item.kind === "user").length, 1);
assert.equal(state.items.find((item) => item.kind === "user")?.id, userID);
}
// A full active prefix is fetched from the same immutable cut before install.
{
const content = deferred<{ data: string; nextOffset: number; done: boolean; stale: boolean }>();
const snapshot = cut();
snapshot.records[1].refs = [{ snapshotId: "cut-1", recordId: "m:assistant", path: ["content"], bytes: 11 }];
let committed: TranscriptSnapshot | undefined;
const projector = new TurnEventProjector(quietTransport);
projector.bind(() => {});
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => snapshot, content: () => content.promise }, projector);
const loading = client.load("tab", (value) => { committed = value; });
await Promise.resolve();
assert.equal(committed, undefined);
content.resolve({ data: "full prefix", nextOffset: 11, done: true, stale: false });
await loading;
assert.equal((committed as TranscriptSnapshot | undefined)?.records[1].message.content, "full prefix");
}
// Session replacement fences both snapshot responses and content reads.
{
const old = deferred<TranscriptSnapshot>();
const projector = new TurnEventProjector(quietTransport);
projector.bind(() => {});
const client = new TranscriptSnapshotClient({ ...transport, snapshot: () => old.promise }, projector);
let commits = 0;
const loading = client.load("tab", () => { commits++; });
client.release("tab");
old.resolve(cut());
assert.equal(await loading, false);
assert.equal(commits, 0);
}
// Older pages cannot resurrect a discarded active attempt or replace a live
// tool result. Rows split across page boundaries merge by tool call identity.
{
let state = initialState;
const projector = new TurnEventProjector(quietTransport);
const newest = cut({ records: [{ id: "tool:call", order: 2, message: { role: "tool", content: "result", toolCallId: "call", toolName: "read_file" }, refs: [] }],
activeRecords: [cut().records[1]], totalRecords: 3, before: 2, hasOlder: true });
const older = cut({ records: [cut().records[0], { ...cut().records[1], message: { ...cut().records[1].message,
toolCalls: [{ id: "call", name: "read_file", arguments: "args" }] } }], totalRecords: 3 });
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => newest, page: async () => older }, projector);
projector.bind((event) => { client.observeEvent("tab", event); state = reducer(state, { type: "event", e: event }); });
await client.load("tab", (snapshot) => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
await client.older("tab", (snapshot) => { state = reducer(state, { type: "transcript_page", snapshot }); });
const tool = state.items.find((item) => item.kind === "tool");
assert.equal(tool?.kind === "tool" && tool.args, "args");
assert.equal(tool?.kind === "tool" && tool.output, "result");
assert.equal(state.items.filter((item) => item.id === "call").length, 1);
}
// A delayed content response cannot overwrite a text mutation after the cut.
{
const snapshot = cut({ activeAttempts: [], runtime: { status: "completed", pendingEvents: [] } });
snapshot.records[1].message.pending = false;
snapshot.records[1].refs = [{ snapshotId: "cut-1", recordId: "m:assistant", path: ["content"], bytes: 4 }];
const content = deferred<{ data: string; nextOffset: number; done: boolean; stale: boolean }>();
const projector = new TurnEventProjector(quietTransport);
let state = initialState;
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => snapshot, content: () => content.promise }, projector);
projector.bind((event) => { client.observeEvent("tab", event); state = reducer(state, { type: "event", e: event }); });
await client.load("tab", (value) => { state = reducer(state, { type: "transcript_snapshot", snapshot: value }); });
const pending = resolveSnapshotItems(client, "tab", "m:assistant", () => state, historyMessagesToItems,
(patches) => { state = reducer(state, { type: "history_items_patch", patches }); });
projector.receiveLive("tab", { kind: "text", seq: 5, runtimeEpoch: "epoch", messageId: "assistant", text: " new" });
content.resolve({ data: "old!", nextOffset: 4, done: true, stale: false });
await pending;
assert.equal(state.live?.text, "prefix new");
assert.notEqual(state.items.find((item) => item.kind === "assistant")?.text, "old!");
}
// A pinned active user can precede the newest page by hundreds of records.
// Loading the middle must insert below that user, never move body above it.
{
const record = (id: string, order: number) => ({ id: `m:${id}`, order, message: { role: "assistant", messageId: id, content: id }, refs: [] });
const newest = cut({ records: [record("tail", 4)], activeRecords: [cut().records[0]], totalRecords: 5, before: 4, hasOlder: true, activeAttempts: [] });
const page = cut({ records: [record("middle1", 2), record("middle2", 3)], activeRecords: [cut().records[0]], totalRecords: 5, before: 2, hasOlder: true });
let state = initialState;
const projector = new TurnEventProjector(quietTransport);
projector.bind(() => {});
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => newest, page: async () => page }, projector);
await client.load("tab", (snapshot) => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
await client.older("tab", (snapshot) => { state = reducer(state, { type: "transcript_page", snapshot }); });
assert.deepEqual(state.items.map((item) => item.id), ["m:user", "m:middle1", "m:middle2", "m:tail"]);
}
// A failed attempt removed by the suffix is a tombstone for older pages.
{
let state = initialState;
const newest = cut({ records: [], activeRecords: cut().records, before: 2, hasOlder: true });
const projector = new TurnEventProjector(quietTransport);
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => newest, page: async () => cut() }, projector);
projector.bind((event) => { client.observeEvent("tab", event); state = reducer(state, { type: "event", e: event }); });
await client.load("tab", (snapshot) => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
projector.receiveLive("tab", { kind: "stream_attempt", messageId: "assistant", seq: 5, runtimeEpoch: "epoch", streamAttempt: { id: "attempt", action: "discard" } });
await client.older("tab", (snapshot) => { state = reducer(state, { type: "transcript_page", snapshot }); });
assert.equal(state.items.some((item) => item.id === "m:assistant"), false);
}
// Preserving an optimistic bubble's mounted key must not orphan its content
// reference, including when callers still hold that original key.
{
const full = "q".repeat(70000);
let state = reducer(initialState, { type: "user", text: full, seq: 0, submissionId: "submit" });
const mounted = state.items[0].id;
const snapshot = cut({ records: [{ id: "m:user", order: 0,
message: { role: "user", messageId: "user", submissionId: "submit", content: full.slice(0, 4096) },
refs: [{ snapshotId: "cut-1", recordId: "m:user", path: ["content"], bytes: full.length }] }],
activeAttempts: [], runtime: { status: "completed", pendingEvents: [] } });
const projector = new TurnEventProjector(quietTransport);
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => snapshot,
content: async (_, req) => ({ data: full.slice(req.offset, req.offset + 65536), nextOffset: Math.min(full.length, req.offset + 65536),
done: req.offset + 65536 >= full.length, stale: false }) }, projector);
await client.load("tab", (snapshot) => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
assert.equal(state.items[0].id, mounted);
await resolveSnapshotItems(client, "tab", mounted, () => state, historyMessagesToItems,
(patches) => { state = reducer(state, { type: "history_items_patch", patches }); });
assert.ok(state.items[0].kind === "user" && state.items[0].text === full, "complete user body replaces preview");
assert.equal(state.items[0].id, mounted);
}
// A replay too large for the wire is recovered by an authoritative cut. The
// old replay must not reset the cursor installed by its own reset handler.
{
let loads = 0;
let state = initialState;
const resetDone = deferred<void>();
const projector = new TurnEventProjector({ replay: async () => ({ events: [], floorSeq: 1, latestSeq: 5,
nextAfterSeq: 4, hasMore: false, resetRequired: true, runtimeEpoch: "epoch" }) });
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => {
loads++;
return loads === 1 ? cut() : cut({ snapshotId: "cut-2", coveredThroughSeq: 5, projectionRevision: 5 });
} }, projector);
const commit = (snapshot: TranscriptSnapshot) => { state = reducer(state, { type: "transcript_snapshot", snapshot }); };
projector.bind(event => { state = reducer(state, { type: "event", e: event }); });
projector.bindReset(async () => { const loaded = await client.load("tab", commit); resetDone.resolve(); return loaded; });
await client.load("tab", commit);
projector.refresh("tab");
await resetDone.promise;
projector.receiveLive("tab", { kind: "text", seq: 6, runtimeEpoch: "epoch", sessionId: "session", messageId: "assistant", text: " suffix" });
assert.equal(loads, 2);
assert.equal(state.live?.text, "prefix suffix");
}
// Full body loading must not eagerly resolve thought content from the same record.
{
const requests: string[] = [];
const snapshot = cut({ activeAttempts: [], runtime: { pendingEvents: [] }, records: [{ id: "m:finished", order: 0,
message: { role: "assistant", messageId: "finished", content: "body preview", reasoning: "thought preview" },
refs: ["content", "reasoning"].map(field => ({ snapshotId: "cut-1", recordId: "m:finished", path: [field], bytes: 4 })) }] });
let state = initialState;
const projector = new TurnEventProjector(quietTransport);
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => snapshot,
content: async (_, ref) => { requests.push(ref.path[0]); return { data: ref.path[0] === "content" ? "body" : "mind", nextOffset: 4, done: true, stale: false }; } }, projector);
await client.load("lazy", snapshot => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
const id = state.items[0].id;
const resolve = (field: string) => resolveSnapshotItems(client, "lazy", id, () => state, historyMessagesToItems,
patches => { state = reducer(state, { type: "history_items_patch", patches }); }, field);
await resolve("content");
assert.deepEqual(requests, ["content"]);
assert.ok(state.items[0].kind === "assistant" && state.items[0].text === "body" && state.items[0].reasoning === "thought preview");
await resolve("reasoning");
assert.deepEqual(requests, ["content", "reasoning"]);
assert.ok(state.items[0].kind === "assistant" && state.items[0].text === "body" && String(state.items[0].reasoning) === "mind");
client.release("lazy");
}
// Full tool details bypass archived Item previews, never mutate them, and remain
// available after closing/reopening without caching the fetched body in the cut.
for (const referenced of [false, true]) {
const args = JSON.stringify({ command: "x".repeat(70000) });
const output = "result".repeat(14000);
const snapshot = cut({ activeAttempts: [], runtime: { pendingEvents: [] }, records: [
{ id: "m:call", order: 0, message: { role: "assistant", messageId: "call", content: "body preview", toolCalls: [
{ id: "tool-full", name: "bash", arguments: referenced ? "args preview" : args }, { id: "unopened-tool", name: "read_file", arguments: "other preview" }] },
refs: referenced ? [
{ snapshotId: "cut-1", recordId: "m:call", path: ["toolCalls", "0", "arguments"], bytes: args.length },
{ snapshotId: "cut-1", recordId: "m:call", path: ["toolCalls", "1", "arguments"], bytes: 4 },
{ snapshotId: "cut-1", recordId: "m:call", path: ["content"], bytes: 4 },
] : [] },
{ id: "m:result", order: 1, message: { role: "tool", toolCallId: "tool-full", content: referenced ? "output preview" : output },
refs: referenced ? [{ snapshotId: "cut-1", recordId: "m:result", path: ["content"], bytes: output.length }] : [] },
] });
let state = initialState;
let reads = 0;
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => snapshot,
content: async (_, ref) => { reads++; const text = ref.path[0] === "content" ? output : args;
return { data: text, nextOffset: text.length, done: true, stale: false }; } }, new TurnEventProjector(quietTransport));
await client.load("tools", snapshot => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
const original = state.items.find(item => item.id === "tool-full");
for (let attempt = 0; attempt < 2; attempt++) {
const value = JSON.parse((await resolveSnapshotTool(client, "tools", "tool-full", () => state))!);
assert.equal(value.args, args);
assert.equal(value.output, output);
assert.equal(state.items.find(item => item.id === "tool-full"), original, "details do not expand or replace source Items");
}
assert.equal(reads, referenced ? 4 : 0, "reopening rereads only this tool's references, without resolving other calls or assistant content");
client.release("tools");
}
// Independent fields must survive both immediate local commits and batched
// remote React commits, regardless of the other field changing Item identity.
for (const batched of [false, true]) {
const body = deferred<import("../lib/transcriptProtocol").TranscriptContentChunk>();
const thought = deferred<import("../lib/transcriptProtocol").TranscriptContentChunk>();
const snapshot = cut({ activeAttempts: [], runtime: { pendingEvents: [] }, records: [{ id: "m:parallel", order: 0,
message: { role: "assistant", messageId: "parallel", content: "body preview", reasoning: "thought preview" },
refs: ["content", "reasoning"].map(field => ({ snapshotId: "cut-1", recordId: "m:parallel", path: [field], bytes: 4 })) }] });
let state = initialState;
const queued: Array<() => void> = [];
const client = new TranscriptSnapshotClient({ ...transport, snapshot: async () => snapshot,
content: async (_, ref) => ref.path[0] === "content" ? body.promise : thought.promise }, new TurnEventProjector(quietTransport));
await client.load("parallel", snapshot => { state = reducer(state, { type: "transcript_snapshot", snapshot }); });
const read = (field: string) => resolveSnapshotItems(client, "parallel", "m:parallel", () => state, historyMessagesToItems, patches => {
const commit = () => { state = reducer(state, { type: "history_items_patch", patches: rebaseSnapshotContentPatches(state, patches, field) }); };
if (batched) queued.push(commit); else commit();
}, field);
const bodyRead = read("content"), thoughtRead = read("reasoning");
body.resolve({ data: "body", nextOffset: 4, done: true, stale: false }); await bodyRead;
thought.resolve({ data: "mind", nextOffset: 4, done: true, stale: false }); await thoughtRead;
queued.forEach(commit => commit());
assert.ok(state.items[0].kind === "assistant" && state.items[0].text === "body" && state.items[0].reasoning === "mind",
batched ? "remote commits retain both complete fields" : "local field resolution survives an unrelated Item update");
client.release("parallel");
}
console.log("transcript snapshot client races, independent concurrent fields and complete tool details: ok");
@@ -414,6 +414,22 @@ console.log("\ntranscript store");
}
// ── weighted LRU: count, pin, byte budget, re-open ──────────────────────────
{
const store = new TranscriptStore(new FakeBackend([]));
const initial = store.installSlice("reader-v2", "/reader", {
entries: [{ entryId: "m:old", turn: 1, order: 0, message: { role: "user", content: "old reader page" }, refs: [] }],
nextCursor: "", newerCursor: "newer", hasOlder: false, hasNewer: true,
startTurn: 1, endTurn: 1, totalTurns: 100, revision: 1, digest: "generation", stale: false,
});
for (let sequence = 2; sequence < 130; sequence++) {
const updated = store.upsertEntries("reader-v2", "/reader", [{ entryId: `m:tail-${sequence}`, turn: sequence, order: sequence,
message: { role: "assistant", content: "committed tail" }, refs: [] }], sequence);
eq(updated?.items.length, initial.items.length, "distant commits do not replace or grow the reader window");
eq(updated?.items[0]?.id, initial.items[0]?.id, "reader anchor survives distant commits");
}
eq(store.peek("reader-v2", "/reader")?.hasNewer, true, "committed tail remains reachable by forward pagination");
}
{
const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
const store = new TranscriptStore(backend, { maxResidentSessions: 3 });
@@ -19,7 +19,7 @@ const list = async () => { if (listFails) throw new Error("read failure"); retur
const purge = async (path: string) => { calls.push(path); if (path === "b") throw new Error("busy"); sessions = sessions.filter((item) => item.path !== path); };
const restore = async (path: string) => { sessions = sessions.filter((item) => item.path !== path); listFails = true; };
const root = createRoot(document.getElementById("root")!);
const render = (active = true) => <LocaleProvider><TrashPage active={active} onBack={() => {}} list={list} purge={purge} restore={restore} /></LocaleProvider>;
const render = (active = true) => <LocaleProvider><TrashPage active={active} onBack={() => {}} onOpenSession={async () => {}} list={list} purge={purge} restore={restore} /></LocaleProvider>;
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((node) => node.textContent?.trim() === text)!;
await act(async () => root.render(render()));
assert.equal(document.querySelector(".management-modal-backdrop"), null);
@@ -51,9 +51,8 @@ try {
const before = calls.length;
assert.equal(branch().hasAttribute("disabled"), false, `${label}: unavailable action remains focusable for its explanation`);
assert.equal(branch().getAttribute("aria-disabled"), "true", `${label}: reports itself unavailable`);
const described = harness.dom.window.document.getElementById(branch().getAttribute("aria-describedby") ?? "");
assert.ok(described, `${label}: names its reason for assistive tech`);
assert.match(described.textContent ?? "", expect, `${label}: explains the reason it shows`);
assert.equal(branch().getAttribute("aria-describedby"), null, `${label}: needs no remount-sensitive description node`);
assert.match(branch().getAttribute("aria-label") ?? "", expect, `${label}: includes its reason in the accessible name`);
await act(async () => branch().click());
assert.equal(calls.length, before, `${label}: never dispatches`);
};
@@ -5,7 +5,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { useController } from "../lib/useController";
import type { AppBindings } from "../lib/bridge";
import type { ContextInfo, EffortInfo, HistorySliceRequest, Meta, TabMeta, WireEvent } from "../lib/types";
import type { ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, Meta, TabMeta, WireEvent } from "../lib/types";
import { historySliceFromMessages } from "./mockHistorySlice";
import { installDesktopHostStub } from "./desktopHostStub";
@@ -49,6 +49,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
workspacePath: "/repo",
topicId: "topic-a",
topicTitle: "General",
session: { hostId: "local", sessionId: "canonical-a" },
label: "model",
ready: true,
running: false,
@@ -71,6 +72,7 @@ function meta(): Meta {
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
session: { hostId: "local", sessionId: "canonical-a" },
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
@@ -102,6 +104,7 @@ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.win
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
let backendRunning = false;
const backendHistory: HistoryMessage[] = [{ role: "user", content: "hello", messageId: "initial-user", createdAt: 1000, checkpointTurn: 0 }];
let cancelCalls = 0;
let cancelInboxCalls = 0;
let cancelInboxError: Error | null = null;
@@ -139,7 +142,7 @@ const desktopStub = installDesktopHostStub(({
historyLoads += 1;
return historySliceFromMessages(
tabID,
[{ role: "user", content: "hello", createdAt: Date.now(), checkpointTurn: 0 }],
backendHistory,
req,
);
},
@@ -174,10 +177,16 @@ const desktopStub = installDesktopHostStub(({
};
},
SubmitToTab: async () => {},
SubmitToTabWithID: async () => {},
SubmitToTabWithID: async (tabId: string, text: string, submissionId: string) => {
const messageId = `user-${submissionId}`;
const createdAt = Date.now();
backendHistory.push({ role: "user", content: text, messageId, submissionId, createdAt });
desktopStub.emit("agent:event", { kind: "user_message", tabId, text, messageId, submissionId, createdAt });
},
CancelTab: async () => {
cancelCalls += 1;
backendRunning = false;
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" });
},
CancelTabWithInboxItems: async () => {
cancelInboxCalls += 1;
@@ -188,12 +197,14 @@ const desktopStub = installDesktopHostStub(({
cancelInboxCalls += 1;
if (cancelInboxError) throw cancelInboxError;
backendRunning = false;
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" });
return { discardedItemIds: [...cancelDiscardedItemIDs] };
},
InterruptTurnForTab: async () => {
interruptCalls += 1;
if (interruptError) throw interruptError;
backendRunning = false;
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" });
},
} as Partial<AppBindings> as AppBindings,
},
@@ -230,14 +241,13 @@ await act(async () => {
desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 3, status: "waiting_user" });
for (let step = 0; step < 20; step += 1) await Promise.resolve();
});
eq(turnReplayCalls, 1, "sequence gap replays the durable missing prefix once");
eq(controller?.state.items.find((item) => item.kind === "assistant")?.id, "a:turn-gap:0", "gap replay keeps the stable sampling-segment item id");
eq(turnReplayCalls, 0, "v2 does not consult the retired ledger sequence space");
eq(controller?.state.pendingPrompt, true, "future event projects only after the missing prefix");
await act(async () => {
desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 3, status: "in_progress" });
await Promise.resolve();
});
eq(controller?.state.pendingPrompt, true, "duplicate sequence is ignored idempotently");
eq(controller?.state.pendingPrompt, false, "ordered Follow revision is authoritative despite legacy sequence values");
const historyLoadsBeforeSettlement = historyLoads;
const historyMutationBeforeSettlement = controller?.state.historyMutation.seq ?? 0;
await act(async () => {
@@ -247,10 +257,9 @@ await act(async () => {
// A terminal turn is folded into the bounded durable window independently of
// cancellation. Let that expected read settle before measuring the later
// cancellation path, whose invariant is still that it schedules no reload.
await waitFor("settled history reconcile", () =>
historyLoads > historyLoadsBeforeSettlement &&
(controller?.state.historyMutation.seq ?? 0) > historyMutationBeforeSettlement,
);
await act(async () => { await flushPromises(); });
eq(historyLoads, historyLoadsBeforeSettlement, "completion never rebases from history");
ok((controller?.state.historyMutation.seq ?? 0) >= historyMutationBeforeSettlement, "completion retains the installed transcript");
historyLoads = 0;
backendRunning = true;
@@ -402,7 +411,7 @@ await act(async () => {
await waitFor("event gap runtime snapshot", () => controller?.state.running === false);
eq(cancelCalls, gapCancelCalls, "event gap recovery never replays a state-changing command");
ok(projectedUsers.every(item => controller?.state.items.includes(item)), "event gap recovery retains projected user item identities");
eq(historyLoads, gapHistoryLoads, "same-session event repair does not reload mounted history");
eq(historyLoads, gapHistoryLoads + 1, "transport gap obtains one consistent Follow snapshot");
await act(async () => {
root.unmount();
@@ -141,9 +141,10 @@ await waitFor("history request", () => historyStarted && (desktopStub.events.get
await act(async () => {
desktopStub.emit("agent:event", { kind: "turn_started", tabId: tab.id });
desktopStub.emit("agent:event", { kind: "text", tabId: tab.id, messageId: "active", text: "active prefix" });
await flushPromises();
});
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "turn starts while history is pending");
ok(controller?.state.transcriptConnection === "syncing", "frames wait for atomic baseline installation");
historyGate.resolve(historySliceFromMessages(
tab.id,
@@ -317,6 +317,13 @@ eq(renderedAverage(), "90.00%", "status bar renders the live executor-era sessio
eq(renderedPanelAverage(), "90.00%", "panel ignores its stale private snapshot and matches the status bar");
ok(contextCalls > initialContextCalls, "usage triggers a new ContextUsageForTab snapshot");
// v2 creates the stable assistant host at sampling start. Only subsequent
// token deltas are non-structural updates to the live subscriber.
await act(async () => {
desktopStub.emit("agent:event", { kind: "stream_attempt", tabId: "tab-live-context",
messageId: "mock-active:tab-live-context", streamAttempt: { id: "mock-active:tab-live-context", action: "begin" } });
await flushPromises(20);
});
const rendersBeforeTextBurst = controllerProbeRenders;
await act(async () => {
desktopStub.emit("agent:event", { kind: "text", tabId: "tab-live-context", text: "one " });
@@ -367,6 +367,10 @@ console.log("\nuse controller meta");
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
eq(sameMeta(meta({ sessionGeneration: 1 }), meta({ sessionGeneration: 1 })), true, "identical sessionGeneration is unchanged");
eq(sameMeta(meta({ sessionGeneration: 1 }), meta({ sessionGeneration: 2 })), false, "sessionGeneration changes invalidate meta equality");
eq(sameMeta(
meta({ session: { hostId: "local", sessionId: "a" } }),
meta({ session: { hostId: "local", sessionId: "b" } }),
), false, "SessionRef changes invalidate meta equality when paths are empty");
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
eq(sameMeta(meta({ workspacePath: "/repo" }), meta({ workspacePath: "/other" })), false, "workspace path changes invalidate meta equality");
eq(sameMeta(meta({ gitBranch: "main" }), meta({ gitBranch: "feature" })), false, "git branch changes invalidate meta equality");
@@ -394,6 +398,8 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
const todos = [{ content: "Keep task state", status: "in_progress" }];
const withTodos = metaFromTab(tab(), meta({ canonicalTodos: todos }));
eq(withTodos.canonicalTodos, todos, "optimistic tab metadata preserves canonical todos for the same session");
const canonical = metaFromTab(tab({ sessionId: "canonical", session: { hostId: "local", sessionId: "canonical" } }));
eq(canonical.session?.sessionId, "canonical", "optimistic tab metadata carries canonical SessionRef identity");
}
{
@@ -143,6 +143,7 @@ const desktopStub = installDesktopHostStub(({
},
ResolvePromptForTab: async (tabId: string, promptId: string, turnId: string, _runtimeEpoch: string, _kind: string, answer: unknown) => {
exactAnswerCalls.push({ tabId, turnId, promptId, answers: answer });
if (rejectAnswer && rejectAnswerMessage.includes("not the active turn")) desktopStub.emit("agent:event", { kind: "prompt_answered", tabId, itemId: promptId });
if (rejectAnswer) throw new Error(rejectAnswerMessage);
},
} as Partial<AppBindings> as AppBindings,
@@ -298,7 +299,7 @@ await act(async () => {
await flushPromises();
await flushPromises();
});
eq(controller?.state.runtimeStatusSeq, 700, "baseline stores the backend sequence");
eq(controller?.state.runtimeStatusSeq, 1, "baseline uses the business cut, never the old ledger sequence");
eq(controller?.state.running, false, "first rejection settles to authoritative idle");
await act(async () => {
await controller?.send("second pre-admission rejection");
@@ -175,7 +175,7 @@ await act(async () => {
});
ok(controller?.state.running ?? false, "optimistic submit enters running state without any agent events");
ok(controller?.state.turnActive === false, "missing turn_started leaves no live turn evidence");
ok(watchdogTimers.size === 1, "optimistic submit arms the stale-turn watchdog");
ok(watchdogTimers.size === 0, "v2 never arms a timer that guesses task completion");
const firstTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
if (firstTimer) {
@@ -186,9 +186,9 @@ if (firstTimer) {
await flushPromises();
});
}
ok(listTabsCalls >= 2, "first quiet-period probe reconciles backend runtime state");
ok(listTabsCalls >= 1, "initial tab discovery completed");
ok(controller?.state.running ?? false, "a genuinely running backend remains running");
ok(watchdogTimers.size === 1, "still-running probe re-arms instead of stopping after one check");
ok(watchdogTimers.size === 0, "network silence does not restart a watchdog");
backendRunning = false;
revision = 2;
@@ -205,11 +205,14 @@ if (secondTimer) {
await flushPromises();
});
}
await waitFor("missed answer hydration", () => controller?.state.items.some(
(item) => item.kind === "assistant" && item.text === "recovered answer",
) ?? false);
ok(controller?.state.running === false, "idle backend settles the spinner without switching tabs");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "recovered answer") ?? false, "watchdog hydrates the persisted missed answer");
ok(controller?.state.running === true, "idle metadata alone cannot end the turn");
await act(async () => {
desktopStub.emit("agent:event", { kind: "message", tabId: tabID, messageId: "final", text: "recovered answer" });
desktopStub.emit("agent:event", { kind: "turn_done", tabId: tabID });
await flushPromises();
});
ok(controller?.state.running === false, "ordered backend completion settles the spinner");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "recovered answer") ?? false, "completion updates the same visible transcript");
ok(watchdogTimers.size === 0, "settled turn leaves no watchdog timer behind");
await act(async () => { root.unmount(); });
@@ -124,7 +124,6 @@ console.log("\nworkspace changes git errors");
gitAvailable: true,
},
{
creationMode: true,
history: [{ hash: "1234567890", author: "Agent", date: "2026-07-10T12:00:00Z", message: "older commit" }],
},
);
@@ -22,8 +22,8 @@ const globalRoot = "/fixture/global-workspace";
let navigation!: ReturnType<typeof useSessionNavigationCommands>;
let navigationRequest: unknown;
const setTreeWidth = (width: number) => { restoredWidth = width; };
function Probe({ workspace, creation, visible, sessionId }: { workspace: string; creation: boolean; visible: boolean; sessionId: string }) {
commands = useWorkspacePanelCommands({ sessionId, workspaceRoot: workspace, creation, visible, closeOverlays, clearLiveWidth,
function Probe({ workspace, visible, sessionId }: { workspace: string; visible: boolean; sessionId: string }) {
commands = useWorkspacePanelCommands({ sessionId, workspaceRoot: workspace, visible, closeOverlays, clearLiveWidth,
availableWidth: 800, clampTreeWidth: (width) => width, setTreeWidth, gridOpen: visible, t: (key: string) => key } as never);
navigation = useSessionNavigationCommands({
activeTab: { id: "fixture", scope: workspace === globalRoot ? "global" : "project", workspaceRoot: workspace },
@@ -32,8 +32,8 @@ function Probe({ workspace, creation, visible, sessionId }: { workspace: string;
} as SessionNavigationCommandsInput);
return null;
}
const paint = (workspace: string, creation = false, visible = false, sessionId = `session:${workspace}`) =>
act(async () => root.render(<Probe workspace={workspace} creation={creation} visible={visible} sessionId={sessionId} />));
const paint = (workspace: string, visible = false, sessionId = `session:${workspace}`) =>
act(async () => root.render(<Probe workspace={workspace} visible={visible} sessionId={sessionId} />));
try {
saveWorkspacePanelOpen(false, "A"); saveWorkspacePanelOpen(true, "B");
await paint("A");
@@ -41,7 +41,7 @@ try {
assert.equal(useLayoutStore.getState().workspacePanelOpen, false);
await act(async () => commands.openRightDockMode("changed"));
assert.equal(loadWorkspacePanelOpen("A"), true);
await paint("A", false, true);
await paint("A", true);
await act(async () => { commands.toggleWorkspaceMaximized(); commands.handleWorkspacePreviewModeChange(true); });
assert.equal(useLayoutStore.getState().workspacePanelMaximized, true);
await act(async () => commands.openRightDockMode("context"));
@@ -52,24 +52,56 @@ try {
assert.equal(widthClears, 1);
await paint("B");
assert.equal(useLayoutStore.getState().workspacePanelOpen, true, "different project restores its own preference");
await paint("B", false, true);
await paint("B", true);
assert.deepEqual(useActivityBarStore.getState().tabs.map(tab => tab.type), ["context"],
"an expanded empty dock opens Overview when a session becomes active");
const overviewTabId = useActivityBarStore.getState().activeTabId!;
await act(async () => useActivityBarStore.getState().closeTab(overviewTabId));
await paint("B", false, true);
await paint("B", true);
assert.deepEqual(useActivityBarStore.getState().tabs, [],
"closing the default tab does not reopen it during the same session");
await paint("B", false, true, "session:B:next");
assert.equal(useLayoutStore.getState().workspacePanelOpen, false,
"closing the final dock tab collapses the entire workspace panel");
assert.equal(loadWorkspacePanelOpen("B"), false, "automatic collapse persists for the current project");
await paint("B", true, "session:B:next");
assert.deepEqual(useActivityBarStore.getState().tabs, [], "a new session preserves the collapsed dock");
await act(async () => commands.toggleWorkspacePanel());
await paint("B", true, "session:B:next");
assert.deepEqual(useActivityBarStore.getState().tabs.map(tab => tab.type), ["context"],
"the next session seeds Overview again when the dock is still expanded");
await paint("A", true);
"explicitly reopening the dock in a new session seeds Overview");
await act(async () => commands.openRightDockMode("files"));
await act(async () => useActivityBarStore.getState().closeTab(useActivityBarStore.getState().activeTabId!));
assert.equal(useLayoutStore.getState().workspacePanelOpen, true, "closing one of multiple tabs keeps the dock open");
await act(async () => {
commands.openRightDockMode("changed");
commands.toggleWorkspaceMaximized();
});
const beforeCloseAll = widthClears;
await act(async () => {
useActivityBarStore.getState().tabs.forEach(tab => useActivityBarStore.getState().closeTab(tab.id));
assert.equal(useLayoutStore.getState().workspacePanelOpen, false, "close-all collapses synchronously");
});
assert.equal(useLayoutStore.getState().workspacePanelMaximized, false, "closing all tabs resets maximized mode");
assert.equal(widthClears, beforeCloseAll + 1, "automatic collapse clears the live resize width once");
await act(async () => commands.openDockEntry("files"));
assert.equal(useLayoutStore.getState().workspacePanelOpen, true, "opening an entry expands the dock again");
assert.deepEqual(useActivityBarStore.getState().tabs.map(tab => tab.type), ["file"]);
await act(async () => {
useActivityBarStore.getState().closeTab(useActivityBarStore.getState().activeTabId!);
commands.openRightDockMode("changed");
});
assert.equal(useLayoutStore.getState().workspacePanelOpen, true, "a later open in the same batch wins over the close");
assert.equal(loadWorkspacePanelOpen("B"), true);
saveWorkspacePanelOpen(true, "empty-project");
await paint("empty-project");
assert.equal(useLayoutStore.getState().workspacePanelOpen, true,
"restoring an empty project is not mistaken for closing the final tab");
assert.equal(loadWorkspacePanelOpen("B"), true, "project restoration does not overwrite the previous preference");
await paint("A");
assert.equal(useLayoutStore.getState().workspacePanelOpen, false);
assert.equal(useLayoutStore.getState().rightDockMode, "files", "Creation cannot leave a hidden overview selected");
assert.equal(commands.closeWorkspacePanel, first.closeWorkspacePanel);
assert.equal(commands.openRightDockMode, first.openRightDockMode);
await act(async () => commands.toggleWorkspacePanel());
assert.equal(useLayoutStore.getState().rightDockMode, "files");
await act(async () => commands.openRightDockMode("files"));
const hosts = [{ id: "offline" }, { id: "online" }] as RemoteHostView[];
await act(async () => {
useRemoteStore.getState().setHosts(hosts);
@@ -120,5 +152,5 @@ try {
const before = { closes, widthClears, layout: useLayoutStore.getState() };
first.openRightDockMode("changed"); first.toggleWorkspaceMaximized(); first.closeWorkspacePanel();
assert.deepEqual({ closes, widthClears, layout: useLayoutStore.getState() }, before, "disposed entries cannot change layout or project preferences");
console.log("workspace commands: scoped restoration, Creation, preview/maximize, remote requests and synchronous disposal passed");
console.log("workspace commands: scoped restoration, preview/maximize, remote requests and synchronous disposal passed");
} finally { dom.window.close(); }
@@ -104,7 +104,7 @@ function installDom() {
export async function renderWorkspace(
changes: WorkspaceChangesView,
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
options: { history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
) {
resetWorkspaceTreeMemoryForTests();
setFileNavigationOwner(null);
@@ -138,7 +138,6 @@ export async function renderWorkspace(
cwd="/repo"
maximized={false}
initialViewMode="changed"
creationMode={options.creationMode}
completionSummary={options.completionSummary}
onClose={() => {}}
onToggleMaximized={() => {}}
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { retainPreparedSessionLabels, sessionIsBlank, sessionMetadataPending } from "../lib/workspaceSessionPresentation";
import type { WorkspaceSessionSummary } from "../generated/desktopContract.generated";
const row = { ref: { hostId: "local", sessionId: "existing" }, title: "Previous authored title", preview: "preview", blank: false, metadataStatus: "ready" } as WorkspaceSessionSummary;
const pending = { ...row, title: "", preview: "", blank: true, metadataStatus: "pending" };
assert.equal(sessionIsBlank(pending), false);
assert.equal(sessionMetadataPending(pending), true);
assert.equal(retainPreparedSessionLabels([pending], [row])[0].title, row.title);
assert.equal(retainPreparedSessionLabels([{ ...pending, ref: { ...row.ref, sessionId: "other" } }], [row])[0].title, "");
assert.equal(retainPreparedSessionLabels([{ ...pending, metadataStatus: "ready" }], [row])[0].title, "");
assert.equal(sessionIsBlank({ ...pending, metadataStatus: "ready" }), true);
assert.equal(sessionIsBlank({ ...pending, metadataStatus: "failed" }), false);
console.log("PASS pending vs blank, retained labels, session isolation and authoritative empty metadata");
@@ -1,10 +1,8 @@
import { useEffect } from "react";
import { app } from "../lib/bridge";
import { browserPlatformOverride, normalizeDesktopPlatform } from "../lib/desktopPlatform";
import { useDesktopPreferences } from "./useDesktopPreferences";
import { setDesktopPlatform, setViewportSize, useWindowChromeStore } from "../store/windowChrome";
import {
CREATION_RIGHT_DOCK_TREE_MIN_WIDTH,
RIGHT_DOCK_TREE_MIN_WIDTH,
SIDEBAR_MIN_WIDTH,
saveRightDockTreeWidth,
@@ -24,7 +22,6 @@ export function WindowChromeLifecycle() {
const setSidebarWidth = useLayoutStore((state) => state.setSidebarWidth);
const rightDockTreeWidth = useLayoutStore((state) => state.rightDockTreeWidth);
const setRightDockTreeWidth = useLayoutStore((state) => state.setRightDockTreeWidth);
const { desktopLayoutStyle } = useDesktopPreferences();
useEffect(() => {
document.documentElement.setAttribute("data-platform", platform);
@@ -61,22 +58,16 @@ export function WindowChromeLifecycle() {
}, []);
useEffect(() => {
if (desktopLayoutStyle === "creation" || sidebarWidth >= SIDEBAR_MIN_WIDTH) return;
if (sidebarWidth >= SIDEBAR_MIN_WIDTH) return;
setSidebarWidth(SIDEBAR_MIN_WIDTH);
saveSidebarWidth(SIDEBAR_MIN_WIDTH);
}, [desktopLayoutStyle, setSidebarWidth, sidebarWidth]);
}, [setSidebarWidth, sidebarWidth]);
useEffect(() => {
if (desktopLayoutStyle === "creation") {
if (rightDockTreeWidth >= CREATION_RIGHT_DOCK_TREE_MIN_WIDTH) return;
setRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH);
saveRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH);
return;
}
if (rightDockTreeWidth >= RIGHT_DOCK_TREE_MIN_WIDTH) return;
setRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH);
saveRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH);
}, [desktopLayoutStyle, rightDockTreeWidth, setRightDockTreeWidth]);
}, [rightDockTreeWidth, setRightDockTreeWidth]);
return null;
}
@@ -19,8 +19,8 @@ export function projectControllerProfiles(tabs: readonly TabMeta[], profiles: Re
return [{ target: active.target, profile: runtimeProfile(active.profile), remote: active.remote },
...tabs.filter(tab => tab.id !== active.target.tabId).map(tab => ({
target: { tabId: tab.id, sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath,
sessionId: tab.sessionId,
sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }) },
session: tab.session, sessionGeneration: tab.sessionGeneration,
scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }) },
profile: runtimeProfile(profiles[tab.id] ?? composerProfileFromTab(tab)), remote: Boolean(tab.remote),
}))];
}
@@ -114,16 +114,14 @@ export function projectConversationLayout(input: {
/** Workspace controller scope key: any identity input change re-scopes the composer. */
export function projectWorkspaceScopeKey(input: {
activeTabId: string | undefined;
tabSessionPath: string | undefined;
metaSessionPath: string | undefined;
sessionKey: string;
cwd: string | undefined;
sessionGen: number;
workspaceControllerEpoch: number;
}): string {
return [
input.activeTabId ?? "",
input.tabSessionPath ?? "",
input.metaSessionPath ?? "",
input.sessionKey,
input.cwd ?? "",
input.sessionGen,
input.workspaceControllerEpoch,
@@ -1,10 +1,12 @@
import { CommandCancelled } from "../lib/commandOutcome";
import type { SessionRef } from "../lib/sessionRef";
import type { RemoteTabOpenOptions, RemoteTabRefView, SessionMeta, TabMeta } from "../lib/types";
import type { useAppRuntimeAdapter } from "./useAppRuntimeAdapter";
import { isChannelSession, sidebarImSessionTarget, type SidebarImConnection } from "./sidebarImProjection";
import type { SessionOperationAuthority } from "./useResourceOperations";
export type DesktopNavigationIntent =
| { kind: "canonical-session"; ref: SessionRef }
| { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string }
| { kind: "blank"; scope: string; workspaceRoot: string }
| { kind: "isolated-worktree"; workspaceRoot: string }
@@ -13,7 +15,7 @@ export type DesktopNavigationIntent =
| { kind: "remote-project"; remote: RemoteTabRefView; options: RemoteTabOpenOptions };
type Runtime = ReturnType<typeof useAppRuntimeAdapter>;
export type DesktopNavigationPorts = Pick<Runtime["navigation"],
"isNavigationIntentCurrent" | "activateTopic"
"isNavigationIntentCurrent" | "activateTopic" | "openCanonicalSession"
| "ensureBlankSurface" | "createIsolatedWorktree" | "registeredNavigationIntent" | "switchRemoteTab"> &
Pick<Runtime["sessionActions"], "openChannelSession" | "resumeSession"> & {
listTabs(): Promise<TabMeta[]>;
@@ -62,6 +64,11 @@ export async function executeDesktopNavigation(input: DesktopNavigationCapture,
ports.ensureBlankSurface(scope, scope === "project" ? workspace : "", seq);
checkpoint();
try {
if (request.kind === "canonical-session") {
await ports.openCanonicalSession(request.ref, seq);
checkpoint(); ports.closeHistory();
await refresh(); checkpoint(); ports.reveal(); return;
}
if (request.kind === "remote-project") {
const token = await ports.registeredNavigationIntent(seq);
checkpoint();
@@ -122,6 +129,10 @@ export async function executeDesktopNavigation(input: DesktopNavigationCapture,
ports.reveal(); await refresh();
} catch (error) {
checkpoint();
if (request.kind === "canonical-session") {
ports.notice({ message: error instanceof Error ? error.message : String(error), tone: "error" });
return;
}
if (request.kind === "remote-project") throw error;
if (request.kind === "topic" || request.kind === "blank") {
ports.notice({ key: "history.failedOpenSession", tone: "error" });
@@ -6,22 +6,16 @@ import { applyTerminalThemePreference } from "../lib/terminalTheme";
import { applyConversationWidth } from "../lib/conversationWidth";
import { hydrateReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
import { hydrateSessionExperience } from "../lib/sessionExperience";
import { applyLayoutStyleDefaults } from "../store/layout";
import { loadBotRuntimeStatus } from "./botRuntimeAdapter";
import type { CommandAuthority } from "../lib/commandOutcome";
import type { BotRuntimeStatusView, DesktopStartupSettingsView, SettingsView } from "../lib/types";
export type DesktopPreferencesSnapshot = DesktopStartupSettingsView | SettingsView;
// A stored "classic" predates the style's removal; those installs land on workbench.
export function layoutStyleFromSnapshot(style?: string) {
return style === "creation" ? "creation" : "workbench";
}
export function applyPreferencesAppearance(settings: DesktopPreferencesSnapshot) {
const theme = normalizeThemePreference(settings.desktopTheme);
applyConfiguredBaseAppearance(theme, normalizeThemeStyleForTheme(settings.desktopThemeStyle, theme));
applyTerminalThemePreference(settings.desktopTerminalTheme);
applyConversationWidth(settings.conversationWidth);
applyLayoutStyleDefaults(layoutStyleFromSnapshot(settings.desktopLayoutStyle));
hydrateSessionExperience(settings.sessionExperience);
hydrateReasoningDisplayMode(settings.sessionExperience === "deep" ? "expanded" : "auto", settings.sessionExperience === "deep");
return normalizeLangPref(settings.desktopLanguage);
@@ -1,7 +1,9 @@
import { sessionIdentityStableKey, type SessionIdentityRef } from "../lib/sessionIdentity";
export type SessionIdentityInput = {
tabId?: string;
session?: SessionIdentityRef | null;
sessionPath?: string;
sessionId?: string;
sessionGeneration?: number;
scope?: string;
workspaceRoot?: string;
@@ -10,14 +12,8 @@ export type SessionIdentityInput = {
/** Runtime session identity; intentionally distinct from draft/workspace keys. */
export function sessionIdentityKey(input: SessionIdentityInput): string {
const sessionId = (input.sessionId ?? "").trim();
if (sessionId) {
return ["session-id", input.tabId ?? "", sessionId].join("\u0000");
}
const sessionPath = (input.sessionPath ?? "").trim();
if (sessionPath) {
return ["session", sessionPath, String(input.sessionGeneration ?? 0)].join("\u0000");
}
const canonical = sessionIdentityStableKey(input);
if (canonical) return canonical;
return [
"topic",
input.scope ?? "",
@@ -103,7 +103,7 @@ export function useAppNavigationComposition(input: AppNavigationCompositionInput
closeTransientOverlays,
clearImDetail: () => setSidebarImDetailConnectionId(""),
prepareBlankWorkspace: (workspaceRoot) => {
if (shell.sidebarWorkbench) session.workspacePanelCommands.prepareBlankWorkspace(workspaceRoot);
session.workspacePanelCommands.prepareBlankWorkspace(workspaceRoot);
},
navigation: { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject },
noteNavigationIntent,
@@ -80,6 +80,7 @@ export function useAppRuntimeAdapter() {
closeTab: controller.closeTab,
reorderTabs: controller.reorderTabs,
activateTopic: controller.activateTopic,
openCanonicalSession: controller.openCanonicalSession,
noteNavigationIntent: controller.noteNavigationIntent,
registeredNavigationIntent: controller.registeredNavigationIntent,
isNavigationIntentCurrent: controller.isNavigationIntentCurrent,
@@ -168,10 +168,10 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
switchTab, switchRemoteTab, closeTab, reorderTabs, createIsolatedWorktree,
noteNavigationIntent, registeredNavigationIntent, isNavigationIntentCurrent, reassertVisibleTabAfterStaleNavigation,
commitSingleSurfaceNavigation, activateTopic,
ensureBlankSurface,
ensureBlankSurface, openCanonicalSession,
} = runtime.navigation;
const {
setTransientOverlayDismissSignal, managementActive, desktopLayoutStyle,
setTransientOverlayDismissSignal, managementActive,
windowsFramelessChrome, rightDockMode,
workspacePanelOpen, workspacePanelMaximized, liveTerminalHeight, setLiveWorkspacePanelRenderWidth,
setRightDockTreeWidth, terminalPanelOpen, setSettingsTarget, enterConversation,
@@ -273,7 +273,7 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
}, [activeTab, activeTabId]);
const transcriptGeometrySessionKey = activeSessionIdentity;
const workspaceScopeKey = projectWorkspaceScopeKey({
activeTabId, tabSessionPath: activeTab?.sessionPath, metaSessionPath: state.meta?.sessionPath,
activeTabId, sessionKey: activeSessionIdentity,
cwd: state.meta?.cwd, sessionGen: state.sessionGen, workspaceControllerEpoch,
});
const workspaceTreeMemoryKey = projectWorkspaceTreeMemoryKey({
@@ -591,7 +591,7 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
const workspacePanelCommands = useWorkspacePanelCommands({
sessionId: activeTabId ?? "",
workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
creation: desktopLayoutStyle === "creation", visible: surfaceWorkspacePanelRenderable,
visible: surfaceWorkspacePanelRenderable,
closeOverlays: closeTransientOverlays, clearLiveWidth: setLiveWorkspacePanelRenderWidth,
availableWidth: workspacePanelAvailableWidth, clampTreeWidth: rightDockTreeWidthClamp, setTreeWidth: setRightDockTreeWidth,
gridOpen: surfaceWorkspacePanelGridOpen,
@@ -679,7 +679,7 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
enqueue: useCommittedCommand((intent, seq) => enqueueNavigationWithIntent(intent, seq)) }); const { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject } = useDesktopNavigation({
visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity },
ports: { isNavigationIntentCurrent, activateTopic,
ensureBlankSurface, createIsolatedWorktree, openChannelSession, resumeSession,
ensureBlankSurface, openCanonicalSession, createIsolatedWorktree, openChannelSession, resumeSession,
registeredNavigationIntent, switchRemoteTab, openRemoteProject: desktopBridge.openRemoteProjectTab,
listTabs: desktopBridge.listTabs, applyTabs: setTabMetas, seedTab: seedActiveTabMeta, listSessions, topicAccepted },
setTabRevealSignal, setProjectRevision, setHistory: setHistView, t, showToast,
@@ -67,9 +67,6 @@ export function useAppShellStores() {
const managementActive = page.kind !== "workspace";
const settingsTarget = page.kind === "settings" ? page.tab : null;
const desktopLayoutStyle = preferences.desktopLayoutStyle;
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const sidebarCreation = desktopLayoutStyle === "creation";
const windowsFramelessChrome = desktopPlatform === "windows";
const terminalResizing = liveTerminalHeight !== null;
@@ -90,7 +87,7 @@ export function useAppShellStores() {
remoteHosts, remoteStatuses, requestRemoteExplorer,
desktopPlatform, mainWindowMaximised,
preferences,
managementActive, desktopLayoutStyle, sidebarWorkbench, sidebarCreation,
managementActive,
windowsFramelessChrome, terminalResizing,
};
}
@@ -9,11 +9,15 @@ import { refreshHistoryProjection, type HistoryViewState } from "./historyViewPr
import { useCommittedCommand } from "../lib/useCommittedCommand";
import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing";
import { useResourceOperations, type SessionResource, type SessionOperationAuthority } from "./useResourceOperations";
import { executeDesktopNavigation, type DesktopNavigationCapture, type DesktopNavigationIntent, type DesktopNavigationPorts, type NavigationNotice } from "./desktopNavigationOwner";
import type { DesktopNavigationCapture, DesktopNavigationIntent, DesktopNavigationPorts, NavigationNotice } from "./desktopNavigationOwner";
type QueueInput = { capture: DesktopNavigationCapture; authority: SessionOperationAuthority; result: { error?: unknown; tab?: TabMeta } };
let executeNavigation: typeof import("./desktopNavigationOwner").executeDesktopNavigation | undefined;
async function runQueuedRequest(request: QueueInput) {
try { request.result.tab = await executeDesktopNavigation(request.capture, request.authority); }
try {
executeNavigation ??= (await import("./desktopNavigationOwner")).executeDesktopNavigation;
request.result.tab = await executeNavigation(request.capture, request.authority);
}
catch (error) { request.result.error = error; }
}
async function executeQueuedNavigation(input: { capture: DesktopNavigationCapture; queue: NavigationCoalescingRefs<QueueInput> }, authority: SessionOperationAuthority) {
@@ -9,7 +9,7 @@ import { hydrateReasoningDisplayMode, setReasoningDisplayPending } from "../lib/
import { hydrateSessionExperience } from "../lib/sessionExperience";
import type { BotRuntimeStatusView } from "../lib/types";
import { app } from "../lib/bridge";
import { applyPreferencesAppearance, layoutStyleFromSnapshot, synchronizeDesktopPreferences, type DesktopPreferencesSnapshot } from "./desktopPreferencesAdapter";
import { applyPreferencesAppearance, synchronizeDesktopPreferences, type DesktopPreferencesSnapshot } from "./desktopPreferencesAdapter";
import { sidebarImConnectionsFromBot, sidebarImTopicSourcesFromBot } from "./sidebarImProjection";
export function useDesktopPreferences() {
@@ -48,7 +48,6 @@ export function useDesktopPreferences() {
const sidebarImConnections = useMemo(() => snapshot ? sidebarImConnectionsFromBot(snapshot.bot, t, botRuntime, nativeRuntime) : [], [snapshot, t, botRuntime, nativeRuntime]);
const imTopicSources = useMemo(() => snapshot ? sidebarImTopicSourcesFromBot(snapshot.bot, t) : {}, [snapshot, t]);
return {
desktopLayoutStyle: layoutStyleFromSnapshot(snapshot?.desktopLayoutStyle),
startupUpdateChecksEnabled: snapshot ? snapshot.checkUpdates !== false : startupFailed ? true : null,
statusBarStyle: snapshot?.statusBarStyle === "text" ? "text" as const : "icon" as const,
statusBarItems: snapshot ? normalizeStatusBarItems(snapshot.statusBarItems) : DEFAULT_STATUS_BAR_ITEMS,
@@ -4,7 +4,7 @@ import { useCommittedCommand } from "../lib/useCommittedCommand";
import { activeTabMirror } from "./activeTabMirror";
import { asArray } from "../lib/array";
import { createBoundedRefreshCoordinator, sameTabMetaLists, seedActiveTabMetaList, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT } from "../lib/tabMetaRefresh";
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "../lib/sound";
import { useRuntimeNotifications } from "./useRuntimeNotifications";
import { composerProfileFromTab, defaultComposerProfile, patchComposerProfile, resolvePlanRestoreTabId, shouldRestoreUserPlanModeForProfile, updateUserPlanModeIntent, type ComposerProfile, type UserPlanModeIntents } from "../lib/composerProfile";
import { useRemoteTabOpened } from "../lib/useRemoteTabOpened";
import { recordFrontendDiagnostic } from "../lib/frontendDiagnosticBridge";
@@ -42,7 +42,7 @@ export type RuntimeEventHandlersInput = {
*/
export function useRuntimeEventHandlers(input: RuntimeEventHandlersInput) {
const { activeTabId, workspaceScopeKey, setProjectRevision } = input;
const attentionChimeEvents = useRef(new Set<string>());
const { handleNotification, resetLegacyAttention } = useRuntimeNotifications(activeTabId);
const tabMetaRefreshCoordinatorRef = useRef<ReturnType<typeof createBoundedRefreshCoordinator<TabMeta[]>> | null>(null);
if (!tabMetaRefreshCoordinatorRef.current) {
tabMetaRefreshCoordinatorRef.current = createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT);
@@ -82,9 +82,8 @@ export function useRuntimeEventHandlers(input: RuntimeEventHandlersInput) {
if (event.kind === "turn_done") {
input.setDockRefreshKey((value) => value + 1);
input.setProjectRevision((value) => value + 1);
if (!event.err) playSuccessChime();
}
if (shouldPlayAttentionChimeForEvent(event, attentionChimeEvents.current)) playAttentionChime();
handleNotification(event);
if (shouldRefreshTabMetaForEvent(event.kind)) void refreshTabMetas(undefined, { afterMutation: true });
if (event.kind !== "turn_done") return;
const turnTabId = resolvePlanRestoreTabId(event.tabId, activeTabMirror().current);
@@ -109,7 +108,7 @@ export function useRuntimeEventHandlers(input: RuntimeEventHandlersInput) {
const handleRuntimeReady = useCommittedCommand<RuntimeReadyListener>((readyTabId) => {
recordFrontendDiagnostic("runtime", "runtime.ready", { ready: true, hasActiveTab: Boolean(readyTabId) });
clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
resetLegacyAttention(readyTabId);
void refreshTabMetas();
if (!readyTabId || readyTabId === input.workspaceScopeActiveTabRef.current) {
input.setWorkspaceControllerEpoch((value) => value + 1);
@@ -118,7 +117,7 @@ export function useRuntimeEventHandlers(input: RuntimeEventHandlersInput) {
const handleRuntimeRebuilt = useCommittedCommand<RuntimeRebuiltListener>((rebuiltTabId) => {
recordFrontendDiagnostic("runtime", "runtime.rebuilt", { ready: true, hasActiveTab: Boolean(rebuiltTabId) });
clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
resetLegacyAttention(rebuiltTabId);
if (!rebuiltTabId || rebuiltTabId === input.workspaceScopeActiveTabRef.current) {
input.setWorkspaceControllerEpoch((value) => value + 1);
}
@@ -0,0 +1,39 @@
import { useEffect, useRef } from "react";
import { useCommittedCommand } from "../lib/useCommittedCommand";
import { useT } from "../lib/i18n";
import { useToast } from "../lib/toast";
import type { AttentionChimeEvent } from "../lib/sound";
import type { NotificationOperation, createRuntimeNotifications } from "../lib/runtimeNotifications";
/** Load notification presentation off the initial render path without losing events. */
export function useRuntimeNotifications(activeTabId: string | undefined) {
const owner = useRef<ReturnType<typeof createRuntimeNotifications> | null>(null);
const pending = useRef<NotificationOperation[]>([]);
const t = useT();
const { showToast } = useToast();
const readPorts = useCommittedCommand(() => ({ activeTabId, t, showToast }));
const accept = useCommittedCommand((operation: NotificationOperation) => {
if (owner.current) owner.current.accept(operation);
else pending.current.push(operation);
});
useEffect(() => {
let live = true;
void import("../lib/runtimeNotifications").then(({ createRuntimeNotifications }) => {
if (!live) return;
owner.current = createRuntimeNotifications(readPorts);
for (const operation of pending.current.splice(0)) owner.current.accept(operation);
owner.current.start();
});
return () => {
live = false;
owner.current?.dispose();
owner.current = null;
pending.current = [];
};
}, [readPorts]);
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
});
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
return { handleNotification, resetLegacyAttention };
}
@@ -7,6 +7,7 @@ import type { WorkspaceNavigationPorts } from "./navigationOwner";
import type { ControlResult, SessionMeta, TabMeta } from "../lib/types";
import type { TopicShortcutEntry } from "../lib/topicShortcuts";
import type { Dispatch, SetStateAction } from "react";
import type { SessionRef } from "../lib/sessionRef";
const loadNavigationOwner = () => import("./navigationOwner");
@@ -82,6 +83,11 @@ export function useSessionNavigationCommands(input: SessionNavigationCommandsInp
const onResumeSession = useCommittedCommand((session: SessionMeta): Promise<void> =>
navigation.enqueueNavigation({ kind: "resume-session", session }));
const openCanonicalSession = useCommittedCommand((ref: SessionRef): Promise<void> => {
input.closeTransientOverlays();
input.clearImDetail();
return navigation.enqueueNavigation({ kind: "canonical-session", ref });
});
const onRecoveryCreated = useCommittedCommand(() => {
input.markProjectChanged((value) => value + 1);
@@ -143,6 +149,7 @@ export function useSessionNavigationCommands(input: SessionNavigationCommandsInp
});
return {
openCanonicalSession,
openBlankSession,
handleNewTab,
handleOpenTopic,
Loaded 100 of 497 files, more files were not shown because too many files have changed in this diff. Show more