From 77f00c8dcfa522b3cd083c7e93454ef615610534 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 15:51:30 +0800 Subject: [PATCH 01/12] docs: design stored session resume fix --- ...29-desktop-stored-session-resume-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md diff --git a/docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md b/docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md new file mode 100644 index 0000000000000..a94a3b1e2bbba --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md @@ -0,0 +1,86 @@ +# Issue 83729 Desktop Stored Session Resume Design + +## Problem + +Opening a persisted Desktop session can leave the thread blank even though the +REST transcript is available. The cold resume path starts the transcript fetch +and `session.resume` concurrently, but it does not publish the transcript until +the RPC settles. A delayed or stranded RPC therefore holds usable persisted +history behind the loading state. + +The empty-transcript failure check also relies only on the cached sidebar row's +`message_count`. If that renderer cache is missing or stale, a successful resume +response can report persisted history while an empty transcript is accepted as +a valid session view. + +The durable stored session is not invalid in this failure. Runtime session IDs +are process-local bindings and may be replaced across backend restarts. The fix +must keep those identities separate and must not change the existing stale-route +404 behavior. + +## Goals + +- Paint a valid REST transcript as soon as it arrives, independently of the + `session.resume` RPC settling. +- Never let an older resume attempt overwrite the currently selected session. +- Preserve pending local messages when reattaching the same session. +- Avoid rebuilding a large transcript when the later RPC adds no live tail. +- Enter the existing bounded retry and explicit error state when authoritative + metadata says history exists but no transcript can be painted. + +## Non-Goals + +- Changing the behavior for a stored session that genuinely returns 404. +- Adding a new timeout policy to REST or gateway requests. +- Replacing the existing resume hook with a new state machine. +- Changing watch-window lazy resume behavior. + +## Chosen Design + +The REST prefetch remains concurrent with `session.resume`, but its completion +becomes an independent publication point. When it resolves, the resume attempt +must still match both the current request generation and selected stored session. +The hook then reconciles the persisted messages with the current view, preserves +same-session pending turns and local assistant errors, and publishes only when +the resulting array differs. + +The same reconciled array is retained as the cold resume's local snapshot. When +the RPC later settles without an inflight or queued projection, final resume +reconciliation reuses that array and does not publish the transcript again. If +the RPC carries a live projection, the hook grafts only that tail onto the REST +base and publishes the changed result. + +The stale-result guards remain authoritative. A prefetch belonging to a resume +attempt that is no longer current is ignored; it does not arm a failure latch or +paint another session's transcript. + +## Failure Semantics + +After `session.resume` settles, the hook treats the session as expected to have +history when either: + +- the resolved sidebar/session row has `message_count > 0`, or +- the authoritative resume response has `message_count > 0`. + +If either source says history exists and the pre-recovery transcript is empty, +the hook clears the runtime binding and arms `$resumeFailedSessionId`. The +existing bounded retry flow then retries and ultimately presents the existing +inline error with a manual Retry action. + +A failed REST prefetch remains non-fatal when the RPC supplies a usable +transcript or accurately reports an empty session. Existing RPC-failure REST +fallback and genuine-404 behavior remain unchanged. + +## Testing + +Hook-level tests will prove these behavior contracts: + +1. A resolved REST transcript is visible while `session.resume` remains pending. +2. A stale REST completion cannot overwrite a newer selected session. +3. A missing sidebar row cannot suppress the failure latch when the resume + response reports `message_count > 0` but no messages are available. +4. A later RPC response with no live projection retains the already-published + transcript rather than producing a different message array. + +The focused hook suite, Desktop TypeScript typecheck, and relevant lint checks +must pass before completion. From 661cb90eb65b55b8e0b657ea2c027c367acfbdcd Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:07:36 +0800 Subject: [PATCH 02/12] docs: refine stored session resume design --- ...sue-83729-desktop-stored-session-resume.md | 230 ++++++++++++++++++ ...29-desktop-stored-session-resume-design.md | 86 ------- 2 files changed, 230 insertions(+), 86 deletions(-) create mode 100644 docs/design/issue-83729-desktop-stored-session-resume.md delete mode 100644 docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md diff --git a/docs/design/issue-83729-desktop-stored-session-resume.md b/docs/design/issue-83729-desktop-stored-session-resume.md new file mode 100644 index 0000000000000..2414b6f3c4c7c --- /dev/null +++ b/docs/design/issue-83729-desktop-stored-session-resume.md @@ -0,0 +1,230 @@ +# Issue 83729: Desktop Stored Session Resume + +**Issue:** [#83729](https://github.com/NousResearch/hermes-agent/issues/83729) + +## Problem + +Opening a persisted Desktop session can leave the thread blank even though its +REST transcript is available. Desktop starts `getLatestSessionMessages()` and +`session.resume` concurrently, but the cold path does not publish the REST +result until the RPC settles. A delayed RPC therefore holds usable persisted +history behind the loading state. + +The failure is amplified by two independent mistakes: + +- When `messages_omitted` is true and the REST prefetch is unavailable, the + client still reconciles `resumed.messages`. The gateway deliberately returns + `messages: []` in that case, so the client turns "not carried in this payload" + into an authoritative empty transcript. +- The empty-transcript latch consults only the renderer's cached sidebar row. + When that row is missing or stale, it ignores the resume response's + `message_count` and accepts the fabricated empty transcript. + +The stored session in #83729 is valid. Its durable ID and database history are +separate from the process-local runtime ID that `session.resume` binds. + +## Existing Constraints + +Commit `73c7f68456` fixed #69649 by removing eager REST publication before the +runtime projection rebuild. That change prevents duplicate inflight user rows +and keeps large transcripts from being built three times. This fix deliberately +reintroduces an early publication point, so it must preserve the constraints of +that change rather than simply revert it. + +`apps/desktop/e2e/large-session-resume.spec.ts` is the performance contract: + +- an unchanged cold resume builds the transcript once; +- a cold resume with a live projection may build the persisted base and then the + live tail, for at most two paint bursts; +- a third paint is the old eager-prefetch regression. + +All existing request-generation and selected-session guards remain mandatory. +An older async result must never overwrite a newer foreground selection. + +## Goals + +- Publish a valid REST transcript as soon as it arrives, without waiting for + `session.resume`. +- Never treat `messages_omitted: true` plus `messages: []` as an empty + authoritative transcript. +- Preserve same-session optimistic user rows and pending assistant rows. +- Merge rows that arrive after early REST publication instead of reverting to + the prefetch-time snapshot. +- Reuse the published message array when the RPC adds no live projection. +- Enter the existing bounded retry and explicit error state when available + metadata says history exists but no transcript can be painted. +- Preserve the #69649 large-session paint budget. + +## Chosen Design + +### Independent REST Publication + +The REST prefetch and `session.resume` remain concurrent. The prefetch gets an +independent completion handler which: + +1. verifies that the request generation and selected stored session are still + current; +2. reconciles the REST rows against `$messages.get()` at completion time; +3. preserves same-session optimistic/pending rows and local assistant errors; +4. calls `setMessages` only when the result is not content-equivalent; and +5. retains the successful response for final runtime reconciliation. + +A stale completion is discarded. It does not paint, bind a runtime, or arm a +failure latch. + +### Omitted Messages Are Not Empty Messages + +After the RPC settles, `messages_omitted` controls the source of the transcript: + +- With a successful, identity-matching prefetch, the already-published REST + transcript is the base. +- Without a usable prefetch, the hook makes one authoritative REST fallback + request. It never passes `resumed.messages` to + `reconcileAuthoritativeMessages` when `messages_omitted` is true. +- If the fallback succeeds, it is reconciled and published through the same + guarded path. This is a transcript recovery, not an RPC failure, so it does + not emit a synthetic "Resume failed" notification. +- If the fallback fails, the hook evaluates the empty-transcript failure rule + below. + +For watch windows, the request remains `lazy: true` without `omit_messages` and +the current no-prefetch behavior is unchanged. + +### Settle Against the Latest View + +Final reconciliation reads `$messages.get()` again after the RPC and any REST +fallback settle. It does not reuse the prefetch-time array as the previous +state. This protects optimistic rows and stream events that arrived between the +early paint and runtime binding. + +If the RPC carries `inflight` or `queued`, `appendLiveSessionProjection` grafts +that tail onto the latest view. Otherwise the exact current array is written +into the per-runtime state. The subsequent `syncSessionStateToView` therefore +sees the same reference/content and does not cause another transcript paint. + +This yields one paint for an unchanged cold resume and two for a cold resume +that adds a real live tail. + +## Empty-Transcript Failure Rule + +The hook treats history as expected when either source says it exists: + +```text +sidebar row message_count > 0 +OR +( + resume response message_count > 0 + AND (session was not created by this renderer run OR resume is not running) +) +``` + +The second clause avoids treating an unpersisted, newly created live session's +in-memory projection as proof of a missing REST display transcript. The count is +used only as a `> 0` signal; its numeric value is never compared with the REST +row count because the gateway branches expose different projections: + +- deferred cold resume counts alternation-repaired raw history; +- lazy/watch resume counts display history; +- live reuse counts in-memory history plus any ancestor prefix. + +A branch can have zero raw rows while its visible transcript comes from an +ancestor prefix. Its sidebar row remains the other side of the OR condition. + +When history is expected and the pre-recovery transcript is empty, the hook +clears the foreground runtime binding and arms `$resumeFailedSessionId`. The +existing four-attempt exponential backoff then retries and ultimately displays +the existing inline ErrorState with manual Retry. + +## Runtime Lifecycle + +The latch path must not call `session.close` on `resumed.session_id`. A successful +resume registers the runtime under the stored session key; subsequent retries +hit `_find_live_session_by_key` and reuse that runtime rather than minting four +records. More importantly, a reused runtime may be the valid auto-continue run +whose transcript the renderer is trying to recover. Closing it would destroy the +feature being repaired. + +A client-side RPC timeout can still orphan a server record if the server +registers it but its response never arrives. Existing gateway session-cap +enforcement owns that cleanup; adding cooperative RPC cancellation is outside +this renderer fix. + +## Residual Failure Window + +If both REST attempts fail while `session.resume` is still pending, there is no +transcript to paint and no authoritative response count yet. The shared gateway +client bounds this state with its existing 120-second request timeout; after the +timeout, the current catch path arms retry/error recovery when the cached row +says history exists. + +This change removes the unbounded blank state when REST succeeds, which is the +reported #83729 case, but it does not shorten the dual-failure window. Starting +automatic retries before the pending RPC ends would create concurrent resumes. +A follow-up should add cancellable resume requests before adopting a shorter +deadline. + +## Non-Goals + +- **Genuine 404 navigation:** the current code sends a prior-run stale route to + a fresh draft when both RPC and REST report the session gone. Distinguishing a + boot-time stale route from a user click requires carrying an explicit resume + reason through `useRouteResume`; changing that UX is a separate follow-up and + is not required to fix the valid session in #83729. +- **Warm-cache policy:** the warm path's stale sidebar check is unchanged. A + known non-empty row with an empty cached view is evicted and falls through to + this corrected cold path. Broadening warm-cache behavior would mix activation + recovery into this fix. +- New UI, new timeout settings, or a resume state-machine rewrite. +- Changes to watch-window lazy resume. + +No follow-up issue is created as part of this change; issue creation is an +external tracking action and should be requested separately. + +## Tests + +Hook tests in `use-session-actions.test.tsx` must cover: + +1. REST history paints while `session.resume` is still pending. +2. A stale REST completion cannot overwrite a newer selected session. +3. REST prefetch failure plus `messages_omitted` triggers one REST fallback and + never reconciles the omitted empty array. +4. REST failure plus `message_count > 0` arms the latch when the sidebar row is + absent. +5. REST failure plus `message_count === 0` binds a legitimate empty session and + does not arm the latch. +6. A newly created running session is not falsely latched from its live count. +7. A stream/pending row arriving after early paint survives final settle. +8. Same-session reconnect early paint preserves an optimistic user message. +9. A watch window performs no REST prefetch and keeps its current lazy behavior. +10. An unchanged resume retains the already-published array through final + per-runtime state synchronization. + +The large-session Electron E2E must cover both paint budgets: + +- unchanged cold resume: exactly one transcript paint burst; +- cold resume with background inference/live projection: at most two bursts, + with no duplicate user or assistant rows. + +## Acceptance Criteria + +- With a pending `session.resume` and successful REST response, persisted + messages become visible before the RPC resolves. +- `messages_omitted: true` is never interpreted as authoritative empty history. +- A response reporting expected history cannot settle into + `messagesEmpty && !activeSessionId` without arming recovery. +- New empty sessions and watch windows retain their current behavior. +- An async result from a superseded resume attempt cannot affect the foreground. +- The focused hook suite, Desktop typecheck and lint pass. +- Both cold large-session E2E paint budgets pass, including row de-duplication. + +## Risks And Rollback + +The main risk is reintroducing #69649 by publishing the same large transcript +more than once or by appending an inflight user row already present in REST. The +reference/content-equivalence checks, current-view settle rule and Electron +paint-budget tests are the release guards. + +The change is renderer-local. If it regresses transcript ordering or paint +counts, it can be rolled back without gateway or database migration. The prior +behavior is restored by removing independent REST publication while retaining +the `messages_omitted` source guard and response-count latch as separable fixes. diff --git a/docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md b/docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md deleted file mode 100644 index a94a3b1e2bbba..0000000000000 --- a/docs/superpowers/specs/2026-08-11-issue-83729-desktop-stored-session-resume-design.md +++ /dev/null @@ -1,86 +0,0 @@ -# Issue 83729 Desktop Stored Session Resume Design - -## Problem - -Opening a persisted Desktop session can leave the thread blank even though the -REST transcript is available. The cold resume path starts the transcript fetch -and `session.resume` concurrently, but it does not publish the transcript until -the RPC settles. A delayed or stranded RPC therefore holds usable persisted -history behind the loading state. - -The empty-transcript failure check also relies only on the cached sidebar row's -`message_count`. If that renderer cache is missing or stale, a successful resume -response can report persisted history while an empty transcript is accepted as -a valid session view. - -The durable stored session is not invalid in this failure. Runtime session IDs -are process-local bindings and may be replaced across backend restarts. The fix -must keep those identities separate and must not change the existing stale-route -404 behavior. - -## Goals - -- Paint a valid REST transcript as soon as it arrives, independently of the - `session.resume` RPC settling. -- Never let an older resume attempt overwrite the currently selected session. -- Preserve pending local messages when reattaching the same session. -- Avoid rebuilding a large transcript when the later RPC adds no live tail. -- Enter the existing bounded retry and explicit error state when authoritative - metadata says history exists but no transcript can be painted. - -## Non-Goals - -- Changing the behavior for a stored session that genuinely returns 404. -- Adding a new timeout policy to REST or gateway requests. -- Replacing the existing resume hook with a new state machine. -- Changing watch-window lazy resume behavior. - -## Chosen Design - -The REST prefetch remains concurrent with `session.resume`, but its completion -becomes an independent publication point. When it resolves, the resume attempt -must still match both the current request generation and selected stored session. -The hook then reconciles the persisted messages with the current view, preserves -same-session pending turns and local assistant errors, and publishes only when -the resulting array differs. - -The same reconciled array is retained as the cold resume's local snapshot. When -the RPC later settles without an inflight or queued projection, final resume -reconciliation reuses that array and does not publish the transcript again. If -the RPC carries a live projection, the hook grafts only that tail onto the REST -base and publishes the changed result. - -The stale-result guards remain authoritative. A prefetch belonging to a resume -attempt that is no longer current is ignored; it does not arm a failure latch or -paint another session's transcript. - -## Failure Semantics - -After `session.resume` settles, the hook treats the session as expected to have -history when either: - -- the resolved sidebar/session row has `message_count > 0`, or -- the authoritative resume response has `message_count > 0`. - -If either source says history exists and the pre-recovery transcript is empty, -the hook clears the runtime binding and arms `$resumeFailedSessionId`. The -existing bounded retry flow then retries and ultimately presents the existing -inline error with a manual Retry action. - -A failed REST prefetch remains non-fatal when the RPC supplies a usable -transcript or accurately reports an empty session. Existing RPC-failure REST -fallback and genuine-404 behavior remain unchanged. - -## Testing - -Hook-level tests will prove these behavior contracts: - -1. A resolved REST transcript is visible while `session.resume` remains pending. -2. A stale REST completion cannot overwrite a newer selected session. -3. A missing sidebar row cannot suppress the failure latch when the resume - response reports `message_count > 0` but no messages are available. -4. A later RPC response with no live projection retains the already-published - transcript rather than producing a different message array. - -The focused hook suite, Desktop TypeScript typecheck, and relevant lint checks -must pass before completion. From 09496f3deb8c84a8cc7187910e34945f68796840 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:16:31 +0800 Subject: [PATCH 03/12] docs: close stored resume design gaps --- ...sue-83729-desktop-stored-session-resume.md | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/docs/design/issue-83729-desktop-stored-session-resume.md b/docs/design/issue-83729-desktop-stored-session-resume.md index 2414b6f3c4c7c..9454bbabb4cf2 100644 --- a/docs/design/issue-83729-desktop-stored-session-resume.md +++ b/docs/design/issue-83729-desktop-stored-session-resume.md @@ -34,8 +34,9 @@ that change rather than simply revert it. `apps/desktop/e2e/large-session-resume.spec.ts` is the performance contract: - an unchanged cold resume builds the transcript once; -- a cold resume with a live projection may build the persisted base and then the - live tail, for at most two paint bursts; +- a cold resume with a live projection, an identity correction, or a recovered + local journal tail may build the persisted base and then one corrected result, + for at most two paint bursts; - a third paint is the old eager-prefetch regression. All existing request-generation and selected-session guards remain mandatory. @@ -67,19 +68,27 @@ independent completion handler which: 2. reconciles the REST rows against `$messages.get()` at completion time; 3. preserves same-session optimistic/pending rows and local assistant errors; 4. calls `setMessages` only when the result is not content-equivalent; and -5. retains the successful response for final runtime reconciliation. +5. retains the successful response as a candidate for final runtime + reconciliation. A stale completion is discarded. It does not paint, bind a runtime, or arm a failure latch. +The prefetch cannot be declared usable until the RPC supplies the bound stored +identity. A **usable prefetch** is therefore exactly one which resolved +successfully and whose `session_id` matches `resumed.session_key` / +`resumed.resumed` (with the existing missing-identity compatibility allowance). +Before the RPC settles, a guarded early publication is only a candidate view. + ### Omitted Messages Are Not Empty Messages After the RPC settles, `messages_omitted` controls the source of the transcript: -- With a successful, identity-matching prefetch, the already-published REST - transcript is the base. +- With a usable prefetch, the already-published REST transcript is the base. - Without a usable prefetch, the hook makes one authoritative REST fallback - request. It never passes `resumed.messages` to + request using the stored key bound by the resume response (falling back to the + requested key only when the response omits its identity). It never passes + `resumed.messages` to `reconcileAuthoritativeMessages` when `messages_omitted` is true. - If the fallback succeeds, it is reconciled and published through the same guarded path. This is a transcript recovery, not an RPC failure, so it does @@ -87,6 +96,14 @@ After the RPC settles, `messages_omitted` controls the source of the transcript: - If the fallback fails, the hook evaluates the empty-transcript failure rule below. +An identity mismatch is the important third unusable-prefetch case, alongside a +failed request and an empty result. The hook discards the candidate REST baseline +before rebuilding from the fallback response. It may preserve only local +optimistic/pending rows through the existing pending-turn reconciler; settled +rows from the mismatched candidate cannot remain in the foreground. Publishing +the corrected identity is a legitimate second paint and remains inside the +#69649 budget. + For watch windows, the request remains `lazy: true` without `omit_messages` and the current no-prefetch behavior is unchanged. @@ -102,8 +119,13 @@ that tail onto the latest view. Otherwise the exact current array is written into the per-runtime state. The subsequent `syncSessionStateToView` therefore sees the same reference/content and does not cause another transcript paint. -This yields one paint for an unchanged cold resume and two for a cold resume -that adds a real live tail. +`recoverInFlightTurnJournal` remains after runtime reconciliation. A journaled +tail recovered after a renderer/app crash is another legitimate second paint; +it must not be suppressed to make the unchanged-session budget pass. + +This yields one paint for an unchanged cold resume and at most two for a cold +resume that adds a real live tail, corrects a mismatched identity, or restores a +journal tail. ## Empty-Transcript Failure Rule @@ -114,14 +136,19 @@ sidebar row message_count > 0 OR ( resume response message_count > 0 - AND (session was not created by this renderer run OR resume is not running) + AND session was not created by this renderer run ) ``` -The second clause avoids treating an unpersisted, newly created live session's -in-memory projection as proof of a missing REST display transcript. The count is -used only as a `> 0` signal; its numeric value is never compared with the REST -row count because the gateway branches expose different projections: +The second clause absolutely excludes `createdThisRun` from the runtime-count +source, whether the new session is running or idle. This avoids treating an +unpersisted first turn that failed or was interrupted as proof of a missing REST +display transcript. A real persisted count on its sidebar row remains the other +side of the OR condition. + +The response count is used only as a `> 0` signal; its numeric value is never +compared with the REST row count because the gateway branches expose different +projections: - deferred cold resume counts alternation-repaired raw history; - lazy/watch resume counts display history; @@ -151,11 +178,13 @@ this renderer fix. ## Residual Failure Window -If both REST attempts fail while `session.resume` is still pending, there is no -transcript to paint and no authoritative response count yet. The shared gateway -client bounds this state with its existing 120-second request timeout; after the -timeout, the current catch path arms retry/error recovery when the cached row -says history exists. +If the initial REST prefetch fails while `session.resume` is still pending, +there is no transcript to paint and no resume-bound identity for the second REST +attempt yet. Although the shared client library defaults to 120 seconds, +`HermesGateway` overrides Desktop's effective request timeout to 30 seconds. +After that timeout, the current RPC-failure catch path performs its REST fallback +and arms retry/error recovery if that also fails while cached metadata says +history exists. This change removes the unbounded blank state when REST succeeds, which is the reported #83729 case, but it does not shorten the dual-failure window. Starting @@ -192,12 +221,16 @@ Hook tests in `use-session-actions.test.tsx` must cover: absent. 5. REST failure plus `message_count === 0` binds a legitimate empty session and does not arm the latch. -6. A newly created running session is not falsely latched from its live count. +6. A newly created session, both running and idle cases, is not falsely latched + from its live count. 7. A stream/pending row arriving after early paint survives final settle. 8. Same-session reconnect early paint preserves an optimistic user message. 9. A watch window performs no REST prefetch and keeps its current lazy behavior. 10. An unchanged resume retains the already-published array through final per-runtime state synchronization. +11. An identity-mismatched early prefetch is replaced from the resume-bound + stored key and cannot leave the candidate session's settled rows in the + foreground. The large-session Electron E2E must cover both paint budgets: From f81b3039594bfcb23e17a6f7b640a97c4f0207b4 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:20:47 +0800 Subject: [PATCH 04/12] docs: plan stored session resume implementation --- ...sue-83729-desktop-stored-session-resume.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md diff --git a/docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md b/docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md new file mode 100644 index 0000000000000..83b00a6b9b4e0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md @@ -0,0 +1,363 @@ +# Issue 83729 Desktop Stored Session Resume Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan. + +**Goal:** Make a valid persisted Desktop transcript visible as soon as REST returns, while preserving runtime projection correctness, retry semantics, and the #69649 paint budget. + +**Architecture:** Keep the existing concurrent REST prefetch and `session.resume` request. Publish the guarded REST result as a candidate view immediately, then validate its stored-session identity when the RPC settles. If messages were omitted and that candidate is unavailable or mismatched, fetch once by the resume-bound stored key; finally reconcile live projection and journal recovery against the latest foreground array. + +**Tech Stack:** React 19, TypeScript, nanostores, Vitest/Testing Library, Electron Playwright. + +--- + +## Task 1: Lock Down Candidate Prefetch Publication + +**Files:** +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` + +### Step 1: Write the failing early-publication test + +Add a deferred `session.resume` test in `resumeSession failure recovery`: + +```ts +it('paints REST history while session.resume is still pending', async () => { + const resumeDeferred = deferred() + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + messages: [{ content: 'persisted question', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + + // Start resume without awaiting it, then assert the REST row appears while + // activeSessionId remains null. Resolve the RPC and await completion. +}) +``` + +Assert that the persisted row is visible before `resumeDeferred.resolve(...)`, that the runtime is not bound early, and that the final resume succeeds. + +### Step 2: Run the focused test and confirm RED + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "paints REST history while session.resume is still pending" +``` + +Expected: FAIL because the current hook awaits `resumePromise` before publishing `prefetchedResult`. + +### Step 3: Publish a guarded candidate view from the prefetch completion + +In `resumeSession` cold-path code, add a local guarded publisher that: + +```ts +function publishPersistedMessages( + result: { messages: SessionMessage[]; session_id?: string }, + mode: 'candidate' | 'authoritative' +): ChatMessage[] | null { + if (!isCurrentResume()) return null + + const currentMessages = $messages.get() + const previousMessages = + mode === 'authoritative' + ? preserveLocalPendingTurnMessages([], currentMessages) + : resumedSameSelectedSession + ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) + : currentMessages + const nextMessages = reconcileAuthoritativeMessages(result.messages, previousMessages) + const messagesForView = chatMessageArraysEquivalent(currentMessages, nextMessages) ? currentMessages : nextMessages + + if (messagesForView !== currentMessages) setMessages(messagesForView) + return messagesForView +} +``` + +Adapt names/types to local style. The important contracts are: + +- call `isCurrentResume()` immediately before mutating the foreground; +- read `$messages.get()` at completion time; +- preserve same-session optimistic/pending rows and assistant errors through the existing reconciler; +- use content equivalence so an unchanged candidate reuses the current array; +- do not bind `activeSessionId` from the REST result. + +Await the prefetch while `resumePromise` runs concurrently, publish a successful result immediately, retain its `session_id`, then await the RPC. + +### Step 4: Add stale-completion and same-session tests + +Add tests that: + +- start resume A with deferred REST, complete resume B, then resolve A and prove A cannot replace B's foreground; +- reconnect the currently selected stored session, keep an optimistic user row visible through the early candidate paint, then resolve the RPC and prove the row remains. + +### Step 5: Add latest-view settle test + +After the candidate paints, append a pending/stream row with `setMessages`, resolve an omitted-message RPC without a live projection, and assert final state still contains that row. Also assert the final state uses the current message array when no correction is needed. + +### Step 6: Run focused tests and confirm GREEN + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "REST history|stale REST|same-session reconnect|arriving after early paint" +``` + +Expected: PASS. + +### Step 7: Commit the candidate-publication slice + +```bash +git add apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +git commit -m "fix(desktop): paint stored transcript before resume settles" +``` + +## Task 2: Make Omitted-Message Recovery Identity-Safe + +**Files:** +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` + +### Step 1: Write the failing omitted-message fallback tests + +Add one test for each unusable prefetch form: + +1. Initial REST request rejects; RPC succeeds with `messages_omitted: true`; the hook calls REST once more using the resume-bound key, paints that transcript, and never treats `resumed.messages: []` as authoritative. +2. Candidate REST response identifies `stored-parent`; RPC binds `stored-continuation`; the hook fetches `stored-continuation`, replaces the parent baseline, and leaves no parent settled rows in the foreground. + +For the mismatch case, assert the REST calls are ordered as: + +```ts +expect(getLatestSessionMessages).toHaveBeenNthCalledWith(1, 'stored-1', null) +expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-continuation', null) +``` + +Use the actual profile argument expected by the hook if it differs. + +### Step 2: Run the new tests and confirm RED + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "omitted|identity-mismatched" +``` + +Expected: FAIL because the current fallback is only in the RPC catch path and the success path reconciles omitted `messages: []` when prefetch is unusable. + +### Step 3: Define and enforce usable prefetch + +After `resumePromise` settles, compute: + +```ts +const resumedStoredSessionId = resumed.session_key || resumed.resumed || storedSessionId +const prefetchMatchesResumedSession = + !prefetchedStoredSessionId || + !resumedStoredSessionId || + prefetchedStoredSessionId === resumedStoredSessionId +const usablePrefetch = prefetchApplied && prefetchMatchesResumedSession +``` + +When `resumed.messages_omitted` is true and `usablePrefetch` is false: + +- make one `getLatestSessionMessages(resumedStoredSessionId, sessionProfile)` request; +- guard its completion with `isCurrentResume()`; +- publish it in authoritative mode, which retains only local optimistic/pending rows from the current foreground; +- update the accepted baseline identity; +- swallow fallback failure so the existing latch rule decides recovery; +- do not send a synthetic RPC failure notification. + +If the candidate identity mismatched and the fallback fails, strip its settled rows before evaluating the latch, preserving only local pending rows. Never pass `resumed.messages` to `reconcileAuthoritativeMessages` when `messages_omitted` is true. + +### Step 4: Reconcile the runtime projection against the latest view + +Re-read `$messages.get()` after identity validation/fallback. For omitted messages, use: + +```ts +const projected = appendLiveSessionProjection(currentMessages, resumed) +const preferredMessages = chatMessageArraysEquivalent(currentMessages, projected) + ? currentMessages + : projected +``` + +For non-omitted watch/lazy responses, retain the existing authoritative resume reconciliation. Leave `recoverInFlightTurnJournal` after this step so a restored journal tail remains a legitimate second paint. + +### Step 5: Run the focused tests and confirm GREEN + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "omitted|identity-mismatched|in-flight turn" +``` + +Expected: PASS, including the existing inflight/queued projection test. + +### Step 6: Commit the identity-safe fallback slice + +```bash +git add apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +git commit -m "fix(desktop): recover omitted resume history by bound identity" +``` + +## Task 3: Strengthen the Empty-Transcript Failure Rule + +**Files:** +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` + +### Step 1: Add response-count failure tests + +Cover both sides of the response-count predicate while the sidebar has no matching row: + +- fallback unavailable/empty plus `resumed.message_count > 0` arms `$resumeFailedSessionId`; +- fallback empty plus `resumed.message_count === 0` binds a legitimate empty session and leaves the latch clear. + +Use `messages_omitted: true` in both responses so the tests exercise the fixed path. + +### Step 2: Add newly-created-session tests + +Exercise `createBackendSessionForSend` (or the narrowest existing create harness) so the returned stored ID enters `createdThisRun`. Resume that ID with an empty REST display transcript and a positive runtime `message_count`, parameterized for `running: true` and `running: false`. Assert neither case arms the latch. Do not seed a positive sidebar `message_count`, because that remains an independent expected-history signal. + +### Step 3: Run the tests and confirm RED + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "message_count|newly created session" +``` + +Expected: the response-count test fails because the current latch only reads the sidebar row. + +### Step 4: Implement the combined predicate + +Immediately before journal recovery can mask transcript emptiness, calculate: + +```ts +const responseClaimsHistory = + !createdThisRun.has(storedSessionId) && (resumed.message_count ?? 0) > 0 +const shouldHaveTranscript = sessionShouldHaveTranscript(stored) || responseClaimsHistory +``` + +Arm the existing latch when `shouldHaveTranscript && preferredMessages.length === 0`. Keep the current no-`session.close` lifecycle and reuse behavior. + +### Step 5: Add and verify the watch-window regression test + +Mock `isWatchWindow()` per test if necessary. Assert a watch resume sends `{ lazy: true }`, omits `omit_messages`, makes no REST request, and uses the RPC-carried transcript exactly as before. + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "message_count|newly created session|watch window" +``` + +Expected: PASS. + +### Step 6: Commit the latch slice + +```bash +git add apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +git commit -m "fix(desktop): detect missing history from resume metadata" +``` + +## Task 4: Enforce the #69649 Paint Budget in Electron E2E + +**Files:** +- Modify: `apps/desktop/e2e/large-session-resume.spec.ts` + +### Step 1: Make the unchanged cold budget exact + +Parameterize `assertUnchangedResume` with an expected maximum or exact budget. The cold test must assert: + +```ts +expect(paints.bursts, diagnostic).toBe(1) +``` + +Keep the known warm-resume `fixme` separate and capped at two if it is later re-enabled. + +### Step 2: Observe the cold live-projection resume + +For the `cold resume keeps background inference attached` case, attach the mutation observer after `reloadIntoColdRenderer` and before `openSeededSession`. After the held stream completes, assert: + +```ts +expect(paints.bursts, diagnostic).toBeLessThanOrEqual(2) +``` + +Retain the existing exact one-row assertions for the running user prompt and completed assistant reply. + +### Step 3: Run the focused Electron tests + +Run: + +```bash +cd apps/desktop +npm run build +npx playwright test e2e/large-session-resume.spec.ts --grep "cold resume" +``` + +Expected: the unchanged case records exactly one paint burst; the background/live case records no more than two and has no duplicate rows. + +### Step 4: Commit the E2E contract + +```bash +git add apps/desktop/e2e/large-session-resume.spec.ts +git commit -m "test(desktop): enforce stored resume paint budget" +``` + +## Task 5: Full Verification and Review + +**Files:** +- Review: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` +- Review: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Review: `apps/desktop/e2e/large-session-resume.spec.ts` + +### Step 1: Run the complete hook suite + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx +``` + +Expected: PASS. + +### Step 2: Run static checks + +```bash +cd apps/desktop +npm run typecheck +npm run lint +``` + +Expected: both commands exit 0. + +### Step 3: Run the scoped E2E suite again after the final build + +```bash +cd apps/desktop +npm run build +npx playwright test e2e/large-session-resume.spec.ts --grep "cold resume" +``` + +Expected: PASS with the specified paint and de-duplication budgets. + +### Step 4: Inspect the final diff for lifecycle and scope regressions + +```bash +git diff origin/main --check +git diff --stat origin/main +git diff origin/main -- apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx apps/desktop/e2e/large-session-resume.spec.ts +``` + +Verify explicitly: + +- no `session.close` was added to the latch path; +- no new timeout, UI state, or warm-cache policy was introduced; +- watch-window lazy resume remains intact; +- omitted messages are never reconciled as authoritative history; +- final reconciliation reads `$messages.get()` after all async work; +- unchanged cold resume stays at one paint and correction/live/journal cases stay within two. + +### Step 5: Commit any verification-only adjustments + +If verification required a code or test adjustment, commit it with a focused message. Otherwise leave the preceding implementation commits unchanged. From dbe099c8835f22d63c58d10da13ef0bb0ac4878f Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:24:32 +0800 Subject: [PATCH 05/12] fix(desktop): paint stored transcript before resume settles --- .../hooks/use-session-actions.test.tsx | 187 ++++++++++++++++-- .../hooks/use-session-actions/index.ts | 33 +++- 2 files changed, 197 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 0decc4c5bd556..baca797af67be 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -36,6 +36,7 @@ import { setSessions } from '@/store/session' import { $sessionTiles } from '@/store/session-states' +import type { SessionMessage, SessionResumeResponse } from '@/types/hermes' import { sessionRoute } from '../../routes' import type { ClientSessionState } from '../../types' @@ -68,12 +69,14 @@ const RUNTIME_SESSION_ID = 'rt-new-001' function deferred() { let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void - const promise = new Promise(done => { + const promise = new Promise((done, fail) => { resolve = done + reject = fail }) - return { promise, resolve } + return { promise, reject, resolve } } type HarnessHandle = Pick< @@ -667,6 +670,156 @@ describe('resumeSession failure recovery', () => { await resume!('stored-1', true) } + it('paints REST history while session.resume is still pending', async () => { + const resumeDeferred = deferred() + const persistedMessages = [{ content: 'persisted question', role: 'user', timestamp: 1 }] + + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + messages: persistedMessages, + session_id: 'stored-1' + } as never) + + const requestGateway = vi.fn((method: string) => { + if (method === 'session.resume') { + return resumeDeferred.promise as Promise + } + + return Promise.resolve({} as never) + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumeResult = resume!('stored-1', true) + + try { + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('persisted question')) + expect($activeSessionId.get()).toBeNull() + } finally { + resumeDeferred.resolve({ + info: {}, + message_count: persistedMessages.length, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + }) + await resumeResult + } + + expect($activeSessionId.get()).toBe('runtime-1') + }) + + it('does not let a stale REST completion overwrite a newer selected session', async () => { + const stalePrefetch = deferred<{ messages: SessionMessage[]; session_id: string }>() + + vi.mocked(getLatestSessionMessages).mockImplementation(async storedSessionId => { + if (storedSessionId === 'stored-1') { + return stalePrefetch.promise + } + + return { + messages: [{ content: 'newer session history', role: 'user', timestamp: 2 }], + session_id: 'stored-2' + } as never + }) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: params?.session_id, + session_id: `runtime-${params?.session_id}`, + session_key: params?.session_id + } as never + } + + return {} as never + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + const staleResume = resume!('stored-1', true) + await resume!('stored-2', true) + expect(JSON.stringify($messages.get())).toContain('newer session history') + + stalePrefetch.resolve({ + messages: [{ content: 'stale session history', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + }) + await staleResume + + expect(JSON.stringify($messages.get())).toContain('newer session history') + expect(JSON.stringify($messages.get())).not.toContain('stale session history') + expect($activeSessionId.get()).toBe('runtime-stored-2') + }) + + it('keeps a pending row that arrives after the early REST paint', async () => { + const resumeDeferred = deferred() + const persistedMessages = [ + { content: 'persisted question', role: 'user', timestamp: 1 }, + { content: 'persisted answer', role: 'assistant', timestamp: 2 } + ] + + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + messages: persistedMessages, + session_id: 'stored-1' + } as never) + + const requestGateway = vi.fn((method: string) => { + if (method === 'session.resume') { + return resumeDeferred.promise as Promise + } + + return Promise.resolve({} as never) + }) + + let resumedState: ClientSessionState | undefined + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( + (resume = ready)} + onStateUpdate={(_sessionId, state) => (resumedState = state)} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumeResult = resume!('stored-1', true) + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('persisted answer')) + + const messagesAfterEarlyPaint = [ + ...$messages.get(), + { + id: 'pending-user', + role: 'user' as const, + parts: [{ type: 'text' as const, text: 'arrived while resume was pending' }] + } + ] + act(() => setMessages(messagesAfterEarlyPaint)) + + resumeDeferred.resolve({ + info: {}, + message_count: persistedMessages.length, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + }) + await resumeResult + + expect(JSON.stringify(resumedState?.messages)).toContain('arrived while resume was pending') + expect(resumedState?.messages).toBe(messagesAfterEarlyPaint) + }) + it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => { // session.resume rejects (e.g. timeout against a wedged backend)... const requestGateway = vi.fn(async (method: string) => { @@ -742,19 +895,13 @@ describe('resumeSession failure recovery', () => { vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never) - const requestGateway = vi.fn(async (method: string) => { + const resumeDeferred = deferred() + const requestGateway = vi.fn((method: string) => { if (method === 'session.resume') { - return { - session_id: 'runtime-1', - session_key: 'stored-1', - resumed: 'stored-1', - message_count: 2, - messages: storedMessages, - info: {} - } as never + return resumeDeferred.promise as Promise } - return {} as never + return Promise.resolve({} as never) }) let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null @@ -762,7 +909,21 @@ describe('resumeSession failure recovery', () => { (resume = r)} requestGateway={requestGateway} selectedStoredSessionId="stored-1" /> ) await waitFor(() => expect(resume).not.toBeNull()) - await resume!('stored-1', true) + const resumeResult = resume!('stored-1', true) + + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('earlier answer')) + expect($messages.get().map(message => message.id)).toContain('user-optimistic') + + resumeDeferred.resolve({ + info: {}, + message_count: storedMessages.length, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + }) + await resumeResult expect($messages.get().map(message => message.id)).toContain('user-optimistic') }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 23781c4942a63..e9e9060e463a5 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -929,9 +929,9 @@ export function useSessionActions({ // keeps it from surfacing as unhandled while the prefetch settles. resumePromise.catch(() => undefined) - // Keep both requests concurrent, but do not paint the REST result until - // the runtime resume has also settled. An eager prefetch paint followed - // by the runtime projection rebuilds large transcripts during resume. + // Keep both requests concurrent. A successful REST result can paint the + // persisted baseline before the runtime binding settles; final resume + // reconciliation below reuses that view unless it adds real projection. let prefetchedResult: { messages: SessionMessage[]; session_id?: string } | null = null try { @@ -942,20 +942,33 @@ export function useSessionActions({ // Non-fatal: gateway resume below can still hydrate the session. } - const resumed = await resumePromise - if (!isCurrentResume()) { return } if (prefetchedResult) { + const currentMessages = $messages.get() const previousMessages = resumedSameSelectedSession - ? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages) - : $messages.get() + ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) + : currentMessages - localSnapshot = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages) + const prefetchedMessages = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages) + + localSnapshot = chatMessageArraysEquivalent(currentMessages, prefetchedMessages) + ? currentMessages + : prefetchedMessages prefetchApplied = true prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId + + if (localSnapshot !== currentMessages) { + setMessages(localSnapshot) + } + } + + const resumed = await resumePromise + + if (!isCurrentResume()) { + return } const currentMessages = $messages.get() @@ -974,7 +987,7 @@ export function useSessionActions({ const preferredMessages = prefetchApplied && prefetchMatchesResumedSession && !hasLiveProjection - ? localSnapshot + ? currentMessages : (() => { const previousMessages = resumedSameSelectedSession ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) @@ -987,7 +1000,7 @@ export function useSessionActions({ // on, so the projection alone remains the degraded fallback.) const resumedMessages = resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession - ? appendLiveSessionProjection(localSnapshot, resumed) + ? appendLiveSessionProjection(currentMessages, resumed) : reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages From 323a574d3cf45aa2b8f267da7139e6abc4007428 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:28:02 +0800 Subject: [PATCH 06/12] fix(desktop): recover omitted resume history by bound identity --- .../hooks/use-session-actions.test.tsx | 80 ++++++++++++++ .../hooks/use-session-actions/index.ts | 101 +++++++++++++----- 2 files changed, 153 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index baca797af67be..8fc4b69de5b90 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -820,6 +820,86 @@ describe('resumeSession failure recovery', () => { expect(resumedState?.messages).toBe(messagesAfterEarlyPaint) }) + it('falls back to REST when resume omits messages and the initial prefetch fails', async () => { + vi.mocked(getLatestSessionMessages) + .mockReset() + .mockRejectedValueOnce(new Error('initial prefetch failed')) + .mockResolvedValueOnce({ + messages: [{ content: 'history recovered by bound key', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-1', undefined) + expect(JSON.stringify($messages.get())).toContain('history recovered by bound key') + expect($activeSessionId.get()).toBe('runtime-1') + }) + + it('replaces an identity-mismatched early prefetch using the resume-bound key', async () => { + const resumeDeferred = deferred() + vi.mocked(getLatestSessionMessages) + .mockReset() + .mockResolvedValueOnce({ + messages: [{ content: 'parent session history', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + .mockResolvedValueOnce({ + messages: [{ content: 'continuation session history', role: 'user', timestamp: 2 }], + session_id: 'stored-continuation' + } as never) + + const requestGateway = vi.fn((method: string) => { + if (method === 'session.resume') { + return resumeDeferred.promise as Promise + } + + return Promise.resolve({} as never) + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumeResult = resume!('stored-1', true) + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('parent session history')) + + resumeDeferred.resolve({ + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: 'stored-continuation', + session_id: 'runtime-continuation', + session_key: 'stored-continuation' + }) + await resumeResult + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(1, 'stored-1', undefined) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-continuation', undefined) + expect(JSON.stringify($messages.get())).toContain('continuation session history') + expect(JSON.stringify($messages.get())).not.toContain('parent session history') + expect($activeSessionId.get()).toBe('runtime-continuation') + }) + it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => { // session.resume rejects (e.g. timeout against a wedged backend)... const requestGateway = vi.fn(async (method: string) => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index e9e9060e463a5..b94d7a7dc8a52 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -896,13 +896,35 @@ export function useSessionActions({ try { const watchWindow = isWatchWindow() - let localSnapshot = resumedSameSelectedSession - ? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages) - : $messages.get() - let prefetchApplied = false let prefetchedStoredSessionId: string | null = null + const publishPersistedMessages = ( + persisted: { messages: SessionMessage[]; session_id?: string }, + discardSettledCandidate = false + ): ChatMessage[] | null => { + if (!isCurrentResume()) { + return null + } + + const currentMessages = $messages.get() + const previousMessages = discardSettledCandidate + ? preserveLocalPendingTurnMessages([], currentMessages) + : resumedSameSelectedSession + ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) + : currentMessages + const reconciledMessages = reconcileAuthoritativeMessages(persisted.messages, previousMessages) + const messagesForView = chatMessageArraysEquivalent(currentMessages, reconciledMessages) + ? currentMessages + : reconciledMessages + + if (messagesForView !== currentMessages) { + setMessages(messagesForView) + } + + return messagesForView + } + // REST transcript prefetch and the gateway resume RPC are independent // — run them concurrently so a big session's wall time is // max(prefetch, resume) instead of their sum. The prefetch paints the @@ -947,22 +969,9 @@ export function useSessionActions({ } if (prefetchedResult) { - const currentMessages = $messages.get() - const previousMessages = resumedSameSelectedSession - ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) - : currentMessages - - const prefetchedMessages = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages) - - localSnapshot = chatMessageArraysEquivalent(currentMessages, prefetchedMessages) - ? currentMessages - : prefetchedMessages + publishPersistedMessages(prefetchedResult) prefetchApplied = true prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId - - if (localSnapshot !== currentMessages) { - setMessages(localSnapshot) - } } const resumed = await resumePromise @@ -971,9 +980,7 @@ export function useSessionActions({ return } - const currentMessages = $messages.get() - - // Keep the local snapshot when resume would only reshuffle runtime + // Keep the current view when resume would only reshuffle runtime // projection. When the REST prefetch already hydrated the transcript, // skip converting/reconciling the resume payload entirely — on a // 1000+-message session that second conversion plus the deep @@ -983,23 +990,61 @@ export function useSessionActions({ const prefetchMatchesResumedSession = !prefetchedStoredSessionId || !resumedStoredSessionId || prefetchedStoredSessionId === resumedStoredSessionId + const usablePrefetch = prefetchApplied && prefetchMatchesResumedSession + + if (resumed.messages_omitted && !usablePrefetch) { + try { + const fallback = await getLatestSessionMessages( + resumedStoredSessionId || storedSessionId, + sessionProfile + ) + + if (!isCurrentResume()) { + return + } + + // A mismatched candidate belongs to another stored identity. Drop + // its settled rows before publishing the resume-bound transcript; + // only local optimistic/pending rows may survive the correction. + publishPersistedMessages(fallback, prefetchApplied && !prefetchMatchesResumedSession) + } catch { + if (!isCurrentResume()) { + return + } + + if (prefetchApplied && !prefetchMatchesResumedSession) { + const currentMessages = $messages.get() + const pendingMessages = preserveLocalPendingTurnMessages([], currentMessages) + + if (!chatMessageArraysEquivalent(currentMessages, pendingMessages)) { + setMessages(pendingMessages) + } + } + } + } + + const currentMessages = $messages.get() + const hasLiveProjection = Boolean(resumed.inflight || resumed.queued) + // Older/partial gateway responses may omit the echo flag even though + // this client requested omit_messages. A matching successful prefetch + // is sufficient proof that REST is the transcript authority. + const persistedTranscriptIsAuthority = resumed.messages_omitted || usablePrefetch const preferredMessages = - prefetchApplied && prefetchMatchesResumedSession && !hasLiveProjection + persistedTranscriptIsAuthority && !hasLiveProjection ? currentMessages : (() => { const previousMessages = resumedSameSelectedSession ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) : currentMessages - // Omitted, not empty — same trap as the activate path above. - // The REST prefetch IS the transcript here; the resume payload - // only contributes the live tail, so graft rather than rebuild. - // (Without a usable prefetch there is nothing better to stand - // on, so the projection alone remains the degraded fallback.) + // Omitted, not empty — the REST load is the transcript + // authority, while the resume payload can only contribute its + // live projection. Never reconcile the omitted empty array as + // an authoritative transcript. const resumedMessages = - resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession + persistedTranscriptIsAuthority ? appendLiveSessionProjection(currentMessages, resumed) : reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) From 62102aff21e0df55f3b7084df4dc4a94664ee1a1 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:30:23 +0800 Subject: [PATCH 07/12] fix(desktop): detect missing history from resume metadata --- .../hooks/use-session-actions.test.tsx | 145 +++++++++++++++++- .../hooks/use-session-actions/index.ts | 6 +- 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 8fc4b69de5b90..b2ed2ded07c12 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -36,6 +36,7 @@ import { setSessions } from '@/store/session' import { $sessionTiles } from '@/store/session-states' +import { isWatchWindow } from '@/store/windows' import type { SessionMessage, SessionResumeResponse } from '@/types/hermes' import { sessionRoute } from '../../routes' @@ -65,6 +66,11 @@ vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({ revealTreePane: vi.fn() })) +vi.mock('@/store/windows', async importOriginal => ({ + ...(await importOriginal>()), + isWatchWindow: vi.fn(() => false) +})) + const RUNTIME_SESSION_ID = 'rt-new-001' function deferred() { @@ -81,7 +87,7 @@ function deferred() { type HarnessHandle = Pick< ReturnType, - 'createBackendSessionForSend' | 'selectSidebarItem' | 'startFreshSessionDraft' + 'createBackendSessionForSend' | 'resumeSession' | 'selectSidebarItem' | 'startFreshSessionDraft' > function storedSession(overrides: Partial = {}): SessionInfo { @@ -654,6 +660,7 @@ describe('resumeSession failure recovery', () => { setResumeFailedSessionId(null) setMessages([]) setSessions([]) + vi.mocked(isWatchWindow).mockReset().mockReturnValue(false) vi.restoreAllMocks() }) @@ -900,6 +907,142 @@ describe('resumeSession failure recovery', () => { expect($activeSessionId.get()).toBe('runtime-continuation') }) + it('arms the failure latch from resume message_count when the sidebar row is absent', async () => { + vi.mocked(getLatestSessionMessages).mockReset().mockRejectedValue(new Error('REST unavailable')) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 3, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect($resumeFailedSessionId.get()).toBe('stored-1') + expect($activeSessionId.get()).toBeNull() + }) + + it('binds an empty session when REST fails and resume message_count is zero', async () => { + vi.mocked(getLatestSessionMessages).mockReset().mockRejectedValue(new Error('REST unavailable')) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 0, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect($resumeFailedSessionId.get()).toBeNull() + expect($activeSessionId.get()).toBe('runtime-1') + }) + + it.each([true, false])( + 'does not latch a newly created session from its live count when running=%s', + async running => { + const storedSessionId = `created-session-${String(running)}` + + vi.mocked(getLatestSessionMessages).mockReset().mockResolvedValue({ + messages: [], + session_id: storedSessionId + } as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.create') { + return { + info: {}, + message_count: 0, + messages: [], + session_id: `created-runtime-${String(running)}`, + stored_session_id: storedSessionId + } as never + } + + if (method === 'session.resume') { + return { + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: storedSessionId, + running, + session_id: `resumed-runtime-${String(running)}`, + session_key: storedSessionId + } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( (handle = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + await act(async () => { + await handle!.createBackendSessionForSend() + }) + await act(async () => { + await handle!.resumeSession(storedSessionId, true) + }) + + expect($resumeFailedSessionId.get()).toBeNull() + expect($activeSessionId.get()).toBe(`resumed-runtime-${String(running)}`) + } + ) + + it('keeps watch-window lazy resume off the REST transcript path', async () => { + vi.mocked(isWatchWindow).mockReturnValue(true) + vi.mocked(getLatestSessionMessages).mockReset() + let resumeParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + resumeParams = params + + return { + info: {}, + message_count: 1, + messages: [{ content: 'watch transcript', role: 'user', timestamp: 1 }], + resumed: 'stored-1', + session_id: 'runtime-watch', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).not.toHaveBeenCalled() + expect(resumeParams).toMatchObject({ lazy: true, source: 'desktop' }) + expect(resumeParams).not.toHaveProperty('omit_messages') + expect(JSON.stringify($messages.get())).toContain('watch transcript') + expect($activeSessionId.get()).toBe('runtime-watch') + }) + it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => { // session.resume rejects (e.g. timeout against a wedged backend)... const requestGateway = vi.fn(async (method: string) => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index b94d7a7dc8a52..505f31d6f9033 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -1080,7 +1080,11 @@ export function useSessionActions({ // must not mask a lost transcript (a retry that reloads real history // is safer than surfacing the in-flight turn alone). Recovery only // ever appends, so this matches the final transcript's emptiness. - if (sessionShouldHaveTranscript(stored) && preferredMessages.length === 0) { + const responseClaimsHistory = + !createdThisRun.has(storedSessionId) && (resumed.message_count ?? 0) > 0 + const shouldHaveTranscript = sessionShouldHaveTranscript(stored) || responseClaimsHistory + + if (shouldHaveTranscript && preferredMessages.length === 0) { setActiveSessionId(null) activeSessionIdRef.current = null setResumeFailedSessionId(storedSessionId) From 11d5d742419dce6810f04d9f1f5a08c61a1e40d8 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:39:06 +0800 Subject: [PATCH 08/12] test(desktop): enforce stored resume paint budget --- apps/desktop/e2e/large-session-resume.spec.ts | 97 ++++++++++++++++--- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index 02c5f16937d1f..cd90229546477 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -45,6 +45,11 @@ interface PaintState { timeline: Array<{ mutations: number; time: number }> } +interface PaintBudget { + bursts: number + kind: 'exact' | 'maximum' +} + async function setupSeededDesktop(mockServer?: MockServerOptions): Promise { const mock = await startMockServer(mockServer) const sandbox = createSandbox('large-session') @@ -121,19 +126,62 @@ async function startPaintObserver(page: Page): Promise { if (!viewport) return let additions = 0 + let addedUserRows = 0 let flushTimer: ReturnType | undefined - new MutationObserver(records => { - additions += records.reduce( - (count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0), - 0, + let previousUserIds: string[] = [] + + const userIds = () => + Array.from(viewport.querySelectorAll('[data-role="user"][data-message-id]')).map( + row => row.dataset.messageId ?? '', ) + + const flush = () => { + const nextUserIds = userIds() + + if (nextUserIds.length > 0) { + const onlyPrependedBackfill = + nextUserIds.length > previousUserIds.length && + previousUserIds.every((id, index) => id === nextUserIds[nextUserIds.length - previousUserIds.length + index]) + const rebuiltSameRows = + previousUserIds.length > 0 && + previousUserIds.length === nextUserIds.length && + previousUserIds.every((id, index) => id === nextUserIds[index]) && + addedUserRows > 0 + const transcriptChanged = + previousUserIds.length === 0 || + rebuiltSameRows || + (!onlyPrependedBackfill && + (previousUserIds.length !== nextUserIds.length || + previousUserIds.some((id, index) => id !== nextUserIds[index]))) + + if (transcriptChanged) { + state.bursts += 1 + state.timeline.push({ mutations: additions, time: Date.now() }) + } + + previousUserIds = nextUserIds + } + + additions = 0 + addedUserRows = 0 + } + + new MutationObserver(records => { + for (const record of records) { + if (record.type !== 'childList' || record.addedNodes.length === 0) continue + + additions += 1 + addedUserRows += Array.from(record.addedNodes).filter( + node => + node instanceof Element && + (node.matches('[data-role="user"][data-message-id]') || + node.querySelector('[data-role="user"][data-message-id]')), + ).length + } + if (additions === 0) return if (flushTimer) clearTimeout(flushTimer) - flushTimer = setTimeout(() => { - state.bursts += 1 - state.timeline.push({ mutations: additions, time: Date.now() }) - additions = 0 - }, 30) + flushTimer = setTimeout(flush, 30) }).observe(viewport, { childList: true, subtree: true }) }) } @@ -166,17 +214,23 @@ async function reloadIntoColdRenderer(fixture: SeededFixture): Promise { await openNewSession(fixture.page) } -async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise { +async function assertUnchangedResume(page: Page, testInfo: TestInfo, budget: PaintBudget): Promise { await openSeededSession(page) await page.waitForTimeout(1_000) await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false }) const paints = await paintState(page) expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1) - // A warm session first restores its retained view, then reconciles it with the - // authoritative transcript. That is bounded at two builds; a third paint was - // the old eager-prefetch + runtime-rebuild regression. A cold restore has one. - expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2) + const diagnostic = `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}` + + if (budget.kind === 'exact') { + expect(paints.bursts, diagnostic).toBe(budget.bursts) + } else { + // A warm session first restores its retained view, then reconciles it with + // the authoritative transcript. A third paint is the old eager-prefetch + + // runtime-rebuild regression. + expect(paints.bursts, diagnostic).toBeLessThanOrEqual(budget.bursts) + } } test.describe('large session resume', () => { @@ -192,7 +246,7 @@ test.describe('large session resume', () => { await waitForAppReady(fixture, 120_000) await startPaintObserver(fixture.page) - await assertUnchangedResume(fixture.page, testInfo) + await assertUnchangedResume(fixture.page, testInfo, { bursts: 1, kind: 'exact' }) }) test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { @@ -207,7 +261,7 @@ test.describe('large session resume', () => { await openSeededSession(fixture.page) await openNewSession(fixture.page) await startPaintObserver(fixture.page) - await assertUnchangedResume(fixture.page, testInfo) + await assertUnchangedResume(fixture.page, testInfo, { bursts: 2, kind: 'maximum' }) }) for (const resumeKind of ['fast', 'cold'] as const) { @@ -233,9 +287,20 @@ test.describe('large session resume', () => { if (resumeKind === 'cold') { await reloadIntoColdRenderer(fixture) + await startPaintObserver(fixture.page) } await openSeededSession(fixture.page) + + if (resumeKind === 'cold') { + await fixture.page.waitForTimeout(300) + const paints = await paintState(fixture.page) + expect( + paints.bursts, + `unexpected cold live-resume paint count: ${JSON.stringify(paints.timeline)}`, + ).toBeLessThanOrEqual(2) + } + fixture.mock.releaseHeldStream() await fixture.page.waitForFunction( expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), From e54bfc2df11516c81f8e0782bd04fe79b3b19669 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:40:56 +0800 Subject: [PATCH 09/12] style(desktop): satisfy resume hook lint --- .../src/app/session/hooks/use-session-actions.test.tsx | 3 +++ .../src/app/session/hooks/use-session-actions/index.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index b2ed2ded07c12..7056150d581ea 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -770,6 +770,7 @@ describe('resumeSession failure recovery', () => { it('keeps a pending row that arrives after the early REST paint', async () => { const resumeDeferred = deferred() + const persistedMessages = [ { content: 'persisted question', role: 'user', timestamp: 1 }, { content: 'persisted answer', role: 'assistant', timestamp: 2 } @@ -810,6 +811,7 @@ describe('resumeSession failure recovery', () => { parts: [{ type: 'text' as const, text: 'arrived while resume was pending' }] } ] + act(() => setMessages(messagesAfterEarlyPaint)) resumeDeferred.resolve({ @@ -1119,6 +1121,7 @@ describe('resumeSession failure recovery', () => { vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never) const resumeDeferred = deferred() + const requestGateway = vi.fn((method: string) => { if (method === 'session.resume') { return resumeDeferred.promise as Promise diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 505f31d6f9033..8c8bc89868b6c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -908,12 +908,15 @@ export function useSessionActions({ } const currentMessages = $messages.get() + const previousMessages = discardSettledCandidate ? preserveLocalPendingTurnMessages([], currentMessages) : resumedSameSelectedSession ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) : currentMessages + const reconciledMessages = reconcileAuthoritativeMessages(persisted.messages, previousMessages) + const messagesForView = chatMessageArraysEquivalent(currentMessages, reconciledMessages) ? currentMessages : reconciledMessages @@ -1082,6 +1085,7 @@ export function useSessionActions({ // ever appends, so this matches the final transcript's emptiness. const responseClaimsHistory = !createdThisRun.has(storedSessionId) && (resumed.message_count ?? 0) > 0 + const shouldHaveTranscript = sessionShouldHaveTranscript(stored) || responseClaimsHistory if (shouldHaveTranscript && preferredMessages.length === 0) { From 0b0caca32d31d5b1434d1b5b65bdc542afcbc706 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 16:43:39 +0800 Subject: [PATCH 10/12] docs(desktop): align resume reconciliation comments --- .../src/app/session/hooks/use-session-actions/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 8c8bc89868b6c..8260c5c7f9e69 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -1072,8 +1072,9 @@ export function useSessionActions({ recoveredInFlightTail = inFlightRecovery.applied // Prefetch-hit fast path: `preferredMessages` IS the live `$messages` - // array (already error-merged when `localSnapshot` was built), so reuse - // the ref instead of rebuilding a throwaway transcript+Map every switch. + // array (already error-merged when the persisted baseline was + // published), so reuse the ref instead of rebuilding a throwaway + // transcript+Map every switch. const messagesForView = inFlightRecovery.messages === currentMessages ? currentMessages @@ -1128,9 +1129,8 @@ export function useSessionActions({ ) // updateSessionState stages its view sync through requestAnimationFrame. - // Commit the final, already-reconciled transcript now so resume has one - // additive DOM build instead of an eager prefetch build plus a later - // runtime projection build. + // Publish only when runtime projection or journal recovery materially + // changed the already-visible persisted baseline. if (!chatMessageArraysEquivalent($messages.get(), messagesForView)) { setMessages(messagesForView) } From e5502fea0a745ae5a5f7a3ed6c211d50db287104 Mon Sep 17 00:00:00 2001 From: friendfish Date: Tue, 11 Aug 2026 17:17:08 +0800 Subject: [PATCH 11/12] test(desktop): guard stored resume array reuse --- apps/desktop/e2e/large-session-resume.spec.ts | 5 +++++ .../session/hooks/use-session-actions.test.tsx | 3 +++ .../issue-83729-desktop-stored-session-resume.md | 16 ++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index cd90229546477..164e31bea6000 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -229,6 +229,7 @@ async function assertUnchangedResume(page: Page, testInfo: TestInfo, budget: Pai // A warm session first restores its retained view, then reconciles it with // the authoritative transcript. A third paint is the old eager-prefetch + // runtime-rebuild regression. + expect(paints.bursts, diagnostic).toBeGreaterThanOrEqual(1) expect(paints.bursts, diagnostic).toBeLessThanOrEqual(budget.bursts) } } @@ -295,6 +296,10 @@ test.describe('large session resume', () => { if (resumeKind === 'cold') { await fixture.page.waitForTimeout(300) const paints = await paintState(fixture.page) + expect( + paints.bursts, + `unexpected cold live-resume paint count: ${JSON.stringify(paints.timeline)}`, + ).toBeGreaterThanOrEqual(1) expect( paints.bursts, `unexpected cold live-resume paint count: ${JSON.stringify(paints.timeline)}`, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 7056150d581ea..72849a643db00 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -699,9 +699,11 @@ describe('resumeSession failure recovery', () => { await waitFor(() => expect(resume).not.toBeNull()) const resumeResult = resume!('stored-1', true) + let earlyPaintedMessages = $messages.get() try { await waitFor(() => expect(JSON.stringify($messages.get())).toContain('persisted question')) + earlyPaintedMessages = $messages.get() expect($activeSessionId.get()).toBeNull() } finally { resumeDeferred.resolve({ @@ -717,6 +719,7 @@ describe('resumeSession failure recovery', () => { } expect($activeSessionId.get()).toBe('runtime-1') + expect($messages.get()).toBe(earlyPaintedMessages) }) it('does not let a stale REST completion overwrite a newer selected session', async () => { diff --git a/docs/design/issue-83729-desktop-stored-session-resume.md b/docs/design/issue-83729-desktop-stored-session-resume.md index 9454bbabb4cf2..c536f4b9b5451 100644 --- a/docs/design/issue-83729-desktop-stored-session-resume.md +++ b/docs/design/issue-83729-desktop-stored-session-resume.md @@ -85,6 +85,10 @@ Before the RPC settles, a guarded early publication is only a candidate view. After the RPC settles, `messages_omitted` controls the source of the transcript: - With a usable prefetch, the already-published REST transcript is the base. +- A usable prefetch remains the transcript authority when an older gateway + honors the client's `omit_messages` request but does not echo + `messages_omitted`. Any `inflight` or `queued` projection from that response + is grafted onto the REST baseline instead of replacing it. - Without a usable prefetch, the hook makes one authoritative REST fallback request using the stored key bound by the resume response (falling back to the requested key only when the response omits its identity). It never passes @@ -192,6 +196,18 @@ automatic retries before the pending RPC ends would create concurrent resumes. A follow-up should add cancellable resume requests before adopting a shorter deadline. +When `messages_omitted` is true, both REST attempts fail, and the RPC still +provides `inflight` or `queued`, the existing degraded behavior renders only +that live projection. Because the foreground is then non-empty, the missing- +history latch does not arm. This tail-only view predates the fix and remains a +known residual case rather than broadening this change into partial-history UI. + +If an early prefetch paints successfully but `session.resume` later rejects, +the existing RPC-failure path performs another REST request instead of reusing +the painted result. Reusing it would remove a redundant request, but requires +hoisting successful-prefetch state across the catch boundary and is left as a +separate optimization. + ## Non-Goals - **Genuine 404 navigation:** the current code sends a prior-run stale route to From dcfe9e936c03821be1b3043bb5f39675a09b631e Mon Sep 17 00:00:00 2001 From: friendfish Date: Mon, 17 Aug 2026 12:31:58 +0800 Subject: [PATCH 12/12] test(desktop): harden stored session resume coverage --- apps/desktop/e2e/large-session-resume.spec.ts | 20 +++++++++++++++++-- .../hooks/use-session-actions.test.tsx | 9 ++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index 164e31bea6000..587f541275ddd 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -42,6 +42,7 @@ interface SeededFixture { interface PaintState { bursts: number + pending: boolean timeline: Array<{ mutations: number; time: number }> } @@ -121,7 +122,7 @@ async function submitPrompt(page: Page, prompt: string): Promise { async function startPaintObserver(page: Page): Promise { await page.evaluate(() => { const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> } + const state = { bursts: 0, pending: false, timeline: [] as Array<{ mutations: number; time: number }> } ;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state if (!viewport) return @@ -164,6 +165,7 @@ async function startPaintObserver(page: Page): Promise { additions = 0 addedUserRows = 0 + state.pending = false } new MutationObserver(records => { @@ -180,12 +182,26 @@ async function startPaintObserver(page: Page): Promise { } if (additions === 0) return + state.pending = true if (flushTimer) clearTimeout(flushTimer) flushTimer = setTimeout(flush, 30) }).observe(viewport, { childList: true, subtree: true }) }) } +async function waitForSettledTranscriptPaint(page: Page): Promise { + await page.waitForFunction( + expected => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + const state = (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints + + return Boolean((viewport?.textContent ?? '').includes(expected) && state && state.bursts >= 1 && !state.pending) + }, + OLDEST_SEEDED_TEXT, + { timeout: 30_000 }, + ) +} + async function paintState(page: Page): Promise { const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints) expect(state, 'paint observer should attach to the thread viewport').toBeDefined() @@ -216,7 +232,7 @@ async function reloadIntoColdRenderer(fixture: SeededFixture): Promise { async function assertUnchangedResume(page: Page, testInfo: TestInfo, budget: PaintBudget): Promise { await openSeededSession(page) - await page.waitForTimeout(1_000) + await waitForSettledTranscriptPaint(page) await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false }) const paints = await paintState(page) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index a81fb989b4239..3284a446bb4e9 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -892,6 +892,7 @@ describe('resumeSession failure recovery', () => { }) it('falls back to REST when resume omits messages and the initial prefetch fails', async () => { + setSessions([storedSession({ profile: 'work' })]) vi.mocked(getLatestSessionMessages) .mockReset() .mockRejectedValueOnce(new Error('initial prefetch failed')) @@ -918,8 +919,14 @@ describe('resumeSession failure recovery', () => { await runResume(requestGateway) + expect(ensureGatewayProfile).toHaveBeenCalledWith('work') expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) - expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-1', undefined) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(1, 'stored-1', 'work') + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-1', 'work') + expect(requestGateway).toHaveBeenCalledWith( + 'session.resume', + expect.objectContaining({ profile: 'work', session_id: 'stored-1' }) + ) expect(JSON.stringify($messages.get())).toContain('history recovered by bound key') expect($activeSessionId.get()).toBe('runtime-1') })