From d72579a07e983b7cfd40c73d21744ce0120343af Mon Sep 17 00:00:00 2001 From: chihumyum Date: Wed, 7 Oct 2026 00:50:36 +0800 Subject: [PATCH 1/4] test(desktop): reproduce transcript retry after initial load failure Exercise the reader Retry callback through the observation lifecycle and real transcript controller. Cover initial recovery failure, repeated Retry, session handoff, observation replacement, and unmount. Generated-by: Codex --- .../conversation-transcript-retry.test.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts diff --git a/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts new file mode 100644 index 00000000000..b0c410c48c6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import { act, createElement, Fragment } from 'react'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { StoredMessage } from '@maka/core/session'; +import type { DesktopTranscriptHandle } from '../../preload/transcript-contract.js'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, openDesktopTranscriptHistory } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { withComposerSubmission } from './composer-submission-fixture.js'; + +const A = JSON.stringify(['host', 'A']); +const B = JSON.stringify(['host', 'B']); +const row = (id: string): DesktopSessionSummary => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], hasUnread: false, + status: 'active', backend: 'ai-sdk', revision: 1, runtimeHostId: 'host', + profileId: 'local', profileName: 'Local', profileKind: 'local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask', +}); + +type TranscriptRead = { + sessionId: string; + cancelled: boolean; + closed: boolean; + succeed(text: string): void; + fail(): void; +}; +type TranscriptView = { + messages: readonly StoredMessage[]; + messageLoadError?: string; + messageLoadRetryPending: boolean; + onRetryMessages?: () => void; +}; + +function harness() { + const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions([A, B].map(row)); + const reads: TranscriptRead[] = []; + const services = stubConversationServices(); + services.observation.openTranscript = (sessionKey, onError) => { + const store = new DesktopTranscriptRangeStore(sessionKey); + return createDesktopTranscriptRangeController(store, + openDesktopTranscriptHistory((_sessionKey, accept, registerCancellation) => { + const result = deferred(); + const { sessionId } = parseDesktopSessionKey(sessionKey); + const generation = `read-${reads.length}`; + const read: TranscriptRead = { + sessionId: sessionKey, cancelled: false, closed: false, + succeed(text) { + let deliverySequence = 0; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId, generation, hostEpoch: 'epoch', durableThrough: 1, + beginsAtTurnBoundary: true, hasOlder: false, + durable: [{ sequence: 1, message: { type: 'user', id: generation, text, turnId: generation, ts: 1 } }], + })) accept({ ...batch, deliverySequence: ++deliverySequence }); + result.resolve({ + sessionId, generation, hostEpoch: 'epoch', readThroughMessageId: null, + close: async () => { read.closed = true; }, + acknowledgeTail: async () => {}, loadEarlier: async () => {}, + }); + }, + fail() { result.reject(new Error('transient transcript read failure')); }, + }; + registerCancellation?.(() => { read.cancelled = true; }); + reads.push(read); + return result.promise; + }, sessionKey, (batch) => { store.accept(batch); }), { onError }); + }; + services.observation.subscribeEvents = (_sessionId, _event, phase) => { + phase('ready'); + return () => {}; + }; + let owner!: ReturnType; + let target!: ReturnType; + let transcript!: TranscriptView; + function Transcript(props: TranscriptView) { transcript = props; return null; } + function Shell() { + target = useAppShellSessionUiState(); + owner = useConversationOwner(); + return createElement(Fragment, null, + createElement(ConversationLifecycle, { + refreshSessions: async () => [], onExecutionBoundaryChanged() {}, + showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, + }), + createElement(ConversationTranscriptRegion, { surface: Transcript, localInteractionAvailable: true }), + ); + } + act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: + createElement(SessionCatalogContext.Provider, { value: catalog, children: + createElement(ConversationServicesProvider, { services, children: + createElement(ConversationProvider, { children: withComposerSubmission(createElement(Shell)) }), + }), + }), + }), + }))); + return { + root, catalog, reads, + get owner() { return owner; }, get target() { return target; }, get transcript() { return transcript; }, + async failInitialReads() { + await act(async () => target.setActiveId(A)); + await act(async () => reads[0]!.fail()); + assert.equal(reads.length, 2, 'observation readiness permits one automatic recovery'); + await act(async () => reads[1]!.fail()); + assert.ok(transcript.messageLoadError); + assert.deepEqual(transcript.messages, []); + assert.equal(owner.workspace.transcriptRangeRef.current, undefined); + }, + }; +} + +describe('Conversation transcript retry', () => { + beforeEach(() => { mock.method(console, 'error', () => undefined); }); + afterEach(() => mock.restoreAll()); + afterEach(cleanupFakeDom); + + it('recovers through the reader Retry after the initial read and automatic recovery fail', async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3, 'Retry must issue another transcript read before any publication'); + assert.equal(h.transcript.messageLoadRetryPending, true); + assert.equal(h.owner.workspace.transcriptRangeRef.current, undefined, 'Retry does not publish an unreadable controller'); + await act(async () => h.reads[2]!.succeed('restored A')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['restored A']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.transcript.messageLoadRetryPending, false); + }); + + it('keeps automatic recovery after one initial failure', async () => { + const h = harness(); + await act(async () => h.target.setActiveId(A)); + await act(async () => h.reads[0]!.fail()); + assert.equal(h.reads.length, 2); + await act(async () => h.reads[1]!.succeed('automatic recovery')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['automatic recovery']); + assert.equal(h.transcript.messageLoadError, undefined); + }); + + it('coalesces repeated Retry clicks and permits another attempt after rejection', async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => { h.transcript.onRetryMessages?.(); h.transcript.onRetryMessages?.(); }); + assert.equal(h.reads.length, 3); + await act(async () => h.reads[2]!.fail()); + assert.ok(h.transcript.messageLoadError); + assert.equal(h.transcript.messageLoadRetryPending, false); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 4); + await act(async () => h.reads[3]!.succeed('manual recovery')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['manual recovery']); + assert.equal(h.transcript.messageLoadError, undefined); + }); + + it('does not retry the published Session after another Session is requested', async () => { + const h = harness(); + await act(async () => h.target.setActiveId(A)); + await act(async () => h.reads[0]!.succeed('published A')); + const retryA = h.transcript.onRetryMessages; + await act(async () => { h.target.setActiveId(B); retryA?.(); }); + assert.deepEqual(h.reads.map((read) => read.sessionId), [A, B]); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['published A']); + assert.equal(h.reads[0]!.cancelled, true); + await act(async () => h.reads[1]!.succeed('published B')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['published B']); + }); + + for (const outcome of ['success', 'failure'] as const) { + it(`ignores a late ${outcome} from Retry after switching Sessions`, async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + await act(async () => h.target.setActiveId(B)); + assert.equal(h.reads[2]!.cancelled, true); + await act(async () => h.reads[3]!.succeed('current B')); + await act(async () => outcome === 'success' ? h.reads[2]!.succeed('late A') : h.reads[2]!.fail()); + assert.equal(h.target.activeId, B); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['current B']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.transcript.messageLoadRetryPending, false); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads[4]!.sessionId, B); + await act(async () => h.reads[4]!.succeed('refreshed B')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['refreshed B']); + }); + } + + for (const outcome of ['success', 'failure'] as const) { + it(`ignores a late ${outcome} from an observation replaced within the same Session`, async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + await act(async () => h.catalog.commitSessions([{ ...row(A), profileId: 'replacement' }, row(B)])); + assert.equal(h.reads.length, 4, 'a changed observation authority opens a new controller'); + assert.equal(h.reads[2]!.cancelled, true); + await act(async () => h.reads[3]!.succeed('replacement A')); + await act(async () => outcome === 'success' ? h.reads[2]!.succeed('retired A') : h.reads[2]!.fail()); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['replacement A']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.transcript.messageLoadRetryPending, false); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 5, 'Retry now belongs to the replacement observation'); + await act(async () => h.reads[4]!.succeed('refreshed replacement A')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['refreshed replacement A']); + }); + } + + for (const outcome of ['success', 'failure'] as const) { + it(`retires the retry controller on unmount and ignores late ${outcome}`, async () => { + const h = harness(); + await h.failInitialReads(); + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + const retryA = h.transcript.onRetryMessages; + await act(async () => h.root.unmount()); + assert.equal(h.reads[2]!.cancelled, true); + await act(async () => { + retryA?.(); + if (outcome === 'success') h.reads[2]!.succeed('retired A'); + else h.reads[2]!.fail(); + }); + assert.equal(h.reads.length, 3); + if (outcome === 'success') assert.equal(h.reads[2]!.closed, true); + }); + } +}); From 5948cb6183c03a84a504f1c448903ba8e24c36e0 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Wed, 7 Oct 2026 00:50:49 +0800 Subject: [PATCH 2/4] fix(desktop): retry history through the active observation Keep the observation controller available before the first transcript publication so Retry can recover after the initial read and automatic recovery fail. Fence retry admission and late errors by session selection and observation identity. Generated-by: Codex --- .../controller/use-conversation-observation.ts | 4 ++++ .../conversation/model/conversation-workspace.ts | 6 +++++- .../conversation/model/transcript-commands.ts | 11 ++++++----- .../conversation/ui/conversation-lifecycle.tsx | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts index 3265e3778de..0421864baa2 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts @@ -48,6 +48,7 @@ export function useConversationObservation(options: { ConversationTranscriptController >; transcriptRangeRef: RefBox; + observationRef: import('../model/conversation-workspace.js').ConversationWorkspace['observationRef']; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; }) { @@ -125,6 +126,8 @@ export function useConversationObservation(options: { const controller = options.services.openTranscript(activeId, (error) => { if (!disposed) applyReadError(activeId, error); }); + const observation = { sessionId: activeId, controller }; + options.observationRef.current = observation; const transcript = controller.store; const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, controller, () => !disposed)); @@ -175,6 +178,7 @@ export function useConversationObservation(options: { subscribeSessionEvents(); return () => { disposed = true; + if (options.observationRef.current === observation) options.observationRef.current = undefined; options.endObservation(activeId); observationAttempt += 1; if (observationRetryTimer !== undefined) { diff --git a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts index 258ac69b7be..d488d7f9bc6 100644 --- a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts @@ -46,6 +46,10 @@ export function createConversationWorkspace(catalog: SessionCatalogController, s const activeIdRef = { current: undefined as string | undefined }; const messagesRef = { current: [] as StoredMessage[] }; const transcriptRangeRef = { current: undefined as ConversationTranscriptController | undefined }; + const observationRef = { current: undefined as { + readonly sessionId: string; + readonly controller: ConversationTranscriptController; + } | undefined }; const transientMessagesBySessionRef = { current: new Map>() }; const selectionRevisionRef = { current: 0 }; let seed = INITIAL_LIVE_CONTENT_SEED; @@ -121,7 +125,7 @@ export function createConversationWorkspace(catalog: SessionCatalogController, s }); if (hasNewTaskReloadIntent()) bootstrapSelectionLease.release(); return { - ui, activeIdRef, transcriptRangeRef, bootstrapSelectionLease, commands, + ui, activeIdRef, transcriptRangeRef, observationRef, bootstrapSelectionLease, commands, publishedSession: Object.freeze({ get current() { return activeIdRef.current; } }), messages: reader((value) => value.messages), usage: (model: string | undefined, connectionId: string | undefined) => reader((value) => diff --git a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts index 3066fadda9a..40c611bd9db 100644 --- a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts @@ -22,7 +22,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { ConversationWorkspace } from './conversation-workspace.js'; import { transcriptErrorMessage, transcriptRefreshTitle } from '../../../application/contracts/transcript-copy.js'; export function createTranscriptCommands(workspace: ConversationWorkspace, feedback: { current: { locale: UiLocale; toast: Pick, 'error'> } }) { - const { ui, activeIdRef, transcriptRangeRef } = workspace; + const { ui, activeIdRef, transcriptRangeRef, observationRef } = workspace; const reportError = (sessionId: string, error: unknown) => { const { locale, toast } = feedback.current; const message = transcriptErrorMessage(error, locale, 'refresh'); @@ -51,10 +51,11 @@ export function createTranscriptCommands(workspace: ConversationWorkspace, feedb } }, async retryMessages(sessionId: string) { - if (activeIdRef.current !== sessionId || !ui.messageRetryPending.claim(sessionId)) return; - const controller = transcriptRangeRef.current; - try { await controller?.reload(); } - catch (error) { if (activeIdRef.current === sessionId && transcriptRangeRef.current === controller) reportError(sessionId, error); } + const observation = observationRef.current; + if (!observation || observation.sessionId !== sessionId || !workspace.commands.isSessionSelected(sessionId) + || !ui.messageRetryPending.claim(sessionId)) return; + try { await observation.controller.reload(); } + catch (error) { if (observationRef.current === observation && workspace.commands.isSessionSelected(sessionId)) reportError(sessionId, error); } finally { ui.messageRetryPending.release(sessionId); } }, }; diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx index ad1373e1858..e98851b7e9a 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -108,7 +108,7 @@ export function ConversationLifecycle(props: { services: services.observation, uiLocale, toastApi, activeId: requested?.localState !== 'pending' ? requested?.id : undefined, observationAuthorityRevision: authority.current.generation, - activeIdRef, transcriptRangeRef, + activeIdRef, transcriptRangeRef, observationRef: workspace.observationRef, handleEvent: handlers.handleEvent, publishTranscript: workspace.publishTranscript, commitTranscript: workspace.commitTranscript, From d168c626d99023359ab678cb5845c438b85135f7 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Thu, 8 Oct 2026 08:39:50 +0800 Subject: [PATCH 3/4] fix(desktop): report a failed transcript Retry once The range controller's reload() hands a failure to its recovery, which reports it through the observation's onError (the "Failed to load task" toast and read error), and then rethrows. retryMessages reported the rethrown error again as a refresh failure, so one failed Retry raised two toasts and replaced the read error with the refresh message. Leave the report to the controller, as its own gap and recovery reloads do, and only release the pending Retry. The controller still withholds a reload that lands on the cached transcript, so Retry stays quiet then too. refreshMessages never calls reload() and keeps its own report. The retry test now records error toasts and checks that a failed Retry raises exactly one, keeps the read error, and that a successful Retry raises none. --- .../conversation-transcript-retry.test.ts | 24 ++++++++++++++++--- .../conversation/model/transcript-commands.ts | 3 ++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts index b0c410c48c6..978bd66e3dd 100644 --- a/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts @@ -20,13 +20,14 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import { act, createElement, Fragment } from 'react'; -import { LocaleProvider, ToastProvider } from '@maka/ui'; +import { LocaleProvider, ToastProvider, useToast } from '@maka/ui'; import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopTranscriptHandle } from '../../preload/transcript-contract.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { getDesktopConversationCopy } from '../../renderer/application/contracts/conversation-copy.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; @@ -62,6 +63,9 @@ function harness() { const catalog = createSessionCatalogController(); catalog.commitSessions([A, B].map(row)); const reads: TranscriptRead[] = []; + /** Error toasts, as title then description. */ + const toastErrors: [string, string | undefined][] = []; + const recordedToasts = new WeakSet(); const services = stubConversationServices(); services.observation.openTranscript = (sessionKey, onError) => { const store = new DesktopTranscriptRangeStore(sessionKey); @@ -101,6 +105,15 @@ function harness() { let transcript!: TranscriptView; function Transcript(props: TranscriptView) { transcript = props; return null; } function Shell() { + const toast = useToast(); + if (!recordedToasts.has(toast)) { + recordedToasts.add(toast); + const error = toast.error; + toast.error = (title, description, ...rest) => { + toastErrors.push([title, description]); + return error(title, description, ...rest); + }; + } target = useAppShellSessionUiState(); owner = useConversationOwner(); return createElement(Fragment, null, @@ -121,7 +134,7 @@ function harness() { }), }))); return { - root, catalog, reads, + root, catalog, reads, toastErrors, get owner() { return owner; }, get target() { return target; }, get transcript() { return transcript; }, async failInitialReads() { await act(async () => target.setActiveId(A)); @@ -166,16 +179,21 @@ describe('Conversation transcript retry', () => { it('coalesces repeated Retry clicks and permits another attempt after rejection', async () => { const h = harness(); await h.failInitialReads(); + const readError = h.transcript.messageLoadError; + const reported = h.toastErrors.length; await act(async () => { h.transcript.onRetryMessages?.(); h.transcript.onRetryMessages?.(); }); assert.equal(h.reads.length, 3); await act(async () => h.reads[2]!.fail()); - assert.ok(h.transcript.messageLoadError); + assert.deepEqual(h.toastErrors.slice(reported), [[getDesktopConversationCopy('en').actions.messageReadFailedTitle, readError]], + 'a failed Retry is reported once'); + assert.equal(h.transcript.messageLoadError, readError, 'a failed Retry keeps the read error'); assert.equal(h.transcript.messageLoadRetryPending, false); await act(async () => h.transcript.onRetryMessages?.()); assert.equal(h.reads.length, 4); await act(async () => h.reads[3]!.succeed('manual recovery')); assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['manual recovery']); assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.toastErrors.length, reported + 1, 'a successful Retry reports nothing'); }); it('does not retry the published Session after another Session is requested', async () => { diff --git a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts index 40c611bd9db..b5ad87b2944 100644 --- a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts @@ -54,8 +54,9 @@ export function createTranscriptCommands(workspace: ConversationWorkspace, feedb const observation = observationRef.current; if (!observation || observation.sessionId !== sessionId || !workspace.commands.isSessionSelected(sessionId) || !ui.messageRetryPending.claim(sessionId)) return; + // The controller reports a failed reload through the observation's onError, as for its own reloads. try { await observation.controller.reload(); } - catch (error) { if (observationRef.current === observation && workspace.commands.isSessionSelected(sessionId)) reportError(sessionId, error); } + catch { /* already reported */ } finally { ui.messageRetryPending.release(sessionId); } }, }; From 99f2df61ae9b1418297ed71db610332fca91bc4d Mon Sep 17 00:00:00 2001 From: chihumyum Date: Thu, 8 Oct 2026 09:12:28 +0800 Subject: [PATCH 4/4] fix(desktop): answer a failed Retry over the cached transcript The range controller does not report a failed reload while it holds the cached transcript Main serves during Host reconnection. Since Retry left reporting to the controller, a Retry clicked there (for example after a refresh had set a load error) failed with no feedback at all. Expose that state as holdsCachedTranscript() on the range controller and its conversation port, reusing the controller's own cached check. When reload() rejects, retryMessages now reports the refresh error itself only if the controller holds the cached transcript and the observation is still current and selected. Every other failure is still reported once, by the controller's onError. The retry test can now answer a read with a cached snapshot. It shows a failed Retry over that snapshot raising one refresh toast, alongside the existing single load toast for the live case. The other conversation test fakes return false for the new method. --- .../composer-submission-owner.test.ts | 2 +- .../__tests__/conversation-compaction.test.ts | 2 +- .../main/__tests__/conversation-owner.test.ts | 2 +- .../conversation-transcript-retry.test.ts | 37 +++++++++++++++++-- .../conversation/model/transcript-commands.ts | 8 +++- .../features/conversation/transcript-ports.ts | 1 + .../desktop/desktop-transcript-range-store.ts | 3 ++ 7 files changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index 25db639bcd4..d1b8c7939a4 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -181,7 +181,7 @@ function harness(options: { hasDurableMessage: (id) => messages.some((message) => message.id === id), }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, close: async () => {}, }; }; diff --git a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts index 7d4330fba25..be313c9b306 100644 --- a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts @@ -151,7 +151,7 @@ describe('Conversation owner context compaction', () => { hasDurableMessage: () => false, }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, close: async () => {}, }); services.observation.subscribeEvents = (_sessionId, onEvent, phase) => { diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 69892c3280b..8f10c4a119d 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -74,7 +74,7 @@ function harness(options: { hasDurableMessage: (id) => messages.some((message) => message.id === id), }, ready: async () => {}, waitForDurableMessage: async () => true, - reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {}, close: async () => { resource.closed = true; }, }; }; diff --git a/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts index 978bd66e3dd..d601c8c0aa4 100644 --- a/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-transcript-retry.test.ts @@ -28,6 +28,7 @@ import type { DesktopSessionSummary } from '../../shared/desktop-session-project import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { getDesktopConversationCopy } from '../../renderer/application/contracts/conversation-copy.js'; +import { transcriptRefreshTitle } from '../../renderer/application/contracts/transcript-copy.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; @@ -48,7 +49,8 @@ type TranscriptRead = { sessionId: string; cancelled: boolean; closed: boolean; - succeed(text: string): void; + /** A `cached` answer is the snapshot Main serves while the Host reconnects. */ + succeed(text: string, source?: 'live' | 'cached'): void; fail(): void; }; type TranscriptView = { @@ -73,10 +75,11 @@ function harness() { openDesktopTranscriptHistory((_sessionKey, accept, registerCancellation) => { const result = deferred(); const { sessionId } = parseDesktopSessionKey(sessionKey); - const generation = `read-${reads.length}`; + const index = reads.length; const read: TranscriptRead = { sessionId: sessionKey, cancelled: false, closed: false, - succeed(text) { + succeed(text, source = 'live') { + const generation = `${source === 'cached' ? 'cached:' : ''}read-${index}`; let deliverySequence = 0; for (const batch of encodeDesktopTranscriptSnapshot({ sessionId, generation, hostEpoch: 'epoch', durableThrough: 1, @@ -196,6 +199,34 @@ describe('Conversation transcript retry', () => { assert.equal(h.toastErrors.length, reported + 1, 'a successful Retry reports nothing'); }); + it('reports a failed Retry once while the cached transcript is shown', async () => { + const h = harness(); + await act(async () => h.target.setActiveId(A)); + await act(async () => h.reads[0]!.succeed('cached A', 'cached')); + assert.equal(h.reads.length, 2, 'a cached answer still asks for the live transcript'); + await act(async () => h.reads[1]!.fail()); + assert.deepEqual(h.toastErrors, [], 'the controller withholds failures over the cached transcript'); + let refreshed: boolean | undefined; + await act(async () => { refreshed = await h.owner.commands.refreshMessages(A); }); + assert.equal(refreshed, false, 'a later refresh meets the failed live read'); + const refreshError = h.transcript.messageLoadError; + assert.ok(refreshError); + const reported = h.toastErrors.length; + await act(async () => h.transcript.onRetryMessages?.()); + assert.equal(h.reads.length, 3); + await act(async () => h.reads[2]!.fail()); + assert.deepEqual(h.toastErrors.slice(reported), [[transcriptRefreshTitle('en'), refreshError]], + 'a failed Retry over the cached transcript is reported once'); + assert.equal(h.transcript.messageLoadError, refreshError); + assert.equal(h.transcript.messageLoadRetryPending, false); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['cached A']); + await act(async () => h.transcript.onRetryMessages?.()); + await act(async () => h.reads[3]!.succeed('live A')); + assert.deepEqual(h.transcript.messages.map((message) => message.type === 'user' ? message.text : undefined), ['live A']); + assert.equal(h.transcript.messageLoadError, undefined); + assert.equal(h.toastErrors.length, reported + 1); + }); + it('does not retry the published Session after another Session is requested', async () => { const h = harness(); await act(async () => h.target.setActiveId(A)); diff --git a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts index b5ad87b2944..812e122e440 100644 --- a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts @@ -54,9 +54,13 @@ export function createTranscriptCommands(workspace: ConversationWorkspace, feedb const observation = observationRef.current; if (!observation || observation.sessionId !== sessionId || !workspace.commands.isSessionSelected(sessionId) || !ui.messageRetryPending.claim(sessionId)) return; - // The controller reports a failed reload through the observation's onError, as for its own reloads. + // The controller reports a failed reload through the observation's onError, as for its own + // reloads, except over the cached transcript, where only this Retry can answer the click. try { await observation.controller.reload(); } - catch { /* already reported */ } + catch (error) { + if (observation.controller.holdsCachedTranscript() && observationRef.current === observation + && workspace.commands.isSessionSelected(sessionId)) reportError(sessionId, error); + } finally { ui.messageRetryPending.release(sessionId); } }, }; diff --git a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts index e83eaa025d3..022247c64cc 100644 --- a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts +++ b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts @@ -34,6 +34,7 @@ export interface ConversationTranscriptController { waitForDurableMessage(messageId: string, timeoutMs: number): Promise; loadEarlier(throughSequence?: number): Promise; reload(): Promise; + holdsCachedTranscript(): boolean; observationChanged(phase: 'pending' | 'ready'): void; close(): Promise; } diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index ced7f8ca8b7..14268912b26 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -40,6 +40,8 @@ export interface DesktopTranscriptRangeController { /** One budget of earlier history, or everything down to `throughSequence` in one answer. */ loadEarlier(throughSequence?: number): Promise; reload(): Promise; + /** Whether the range holds the cached transcript, over which a failed reload is not reported. */ + holdsCachedTranscript(): boolean; observationChanged(phase: 'pending' | 'ready'): void; close(): Promise; } @@ -246,6 +248,7 @@ export function createDesktopTranscriptRangeController( }, loadEarlier, reload, + holdsCachedTranscript: cached, observationChanged: recovery.observationChanged, async close() { if (closed) return;