mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
commit
6885a9d88c
497 files changed
+29025
-4753
No files matched your search
+112
-18
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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("; "));
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
@@ -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
Reference in new issue
Block a user