From 6d061ede581b7bb0bb78cf2dcc94ac30dcc034ce Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:15:42 -0700 Subject: [PATCH] fix(desktop): open transcript catches up on every reconnect Closes #94779. A turn that finished while the gateway socket was down never replays its sessions.changed tick, so the open transcript stayed stale until the user reopened the session. The gateway-open effect now also requests one signature-gated tail of the active transcript on every (re)connect (connection-scoped, so a plain session switch adds no read; messaging transcripts already refresh on open via their own effect). Reported-by: Kkkkkuro --- .../contrib/hooks/use-background-sync.test.ts | 59 ++++++++++++++----- .../app/contrib/hooks/use-background-sync.ts | 13 ++++ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index 30f1e4f8d3..509ff19cbe 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -94,11 +94,13 @@ function useSyncHarness({ activeIsMessaging = false, activeSessionId, activeStoredSessionId, + gatewayState = 'open', refreshActiveTranscript }: { activeIsMessaging?: boolean activeSessionId: string | null activeStoredSessionId: string | null + gatewayState?: string refreshActiveTranscript: () => Promise }) { const updateSessionState: Parameters[0]['updateSessionState'] = vi.fn( @@ -116,7 +118,7 @@ function useSyncHarness({ activeSessionId, activeStoredSessionId, freshDraftReady: false, - gatewayState: 'open', + gatewayState, refreshActiveTranscript, refreshCronJobs: vi.fn(), refreshCurrentModel: vi.fn(), @@ -128,17 +130,23 @@ function useSyncHarness({ }) } -function renderSync( - refreshActiveTranscript: () => Promise, - options: { activeIsMessaging?: boolean; activeSessionId?: null | string; activeStoredSessionId?: null | string } = {} -) { - return renderHook(() => - useSyncHarness({ - activeSessionId: ACTIVE_RUNTIME_ID, - activeStoredSessionId: ACTIVE_STORED_ID, - refreshActiveTranscript, - ...options - }) +type SyncOptions = { + activeIsMessaging?: boolean + activeSessionId?: null | string + activeStoredSessionId?: null | string + gatewayState?: string +} + +function renderSync(refreshActiveTranscript: () => Promise, options: SyncOptions = {}) { + return renderHook( + (props: SyncOptions) => + useSyncHarness({ + activeSessionId: ACTIVE_RUNTIME_ID, + activeStoredSessionId: ACTIVE_STORED_ID, + refreshActiveTranscript, + ...props + }), + { initialProps: options } ) } @@ -465,14 +473,15 @@ describe('active transcript refresh', () => { const refresh = vi.fn(async () => undefined) renderSync(refresh) - expect(refresh).not.toHaveBeenCalled() + // Exactly the one connect-time pull (#94779) — no timer after it. + expect(refresh).toHaveBeenCalledTimes(1) await act(async () => { vi.advanceTimersByTime(60_000) await Promise.resolve() }) - expect(refresh).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledTimes(1) }) it('retains the existing periodic backstop for messaging sessions', async () => { @@ -495,11 +504,12 @@ describe('active transcript refresh', () => { it('only defers an external tick while busy, then refreshes once after idle', async () => { $changeEventsAvailable.set(true) - setBusy(true) const refresh = vi.fn(async () => undefined) renderSync(refresh) + refresh.mockClear() // drop the connect-time pull; this test is about busy transitions + act(() => setBusy(true)) act(() => setBusy(false)) expect(refresh).not.toHaveBeenCalled() act(() => setBusy(true)) @@ -514,12 +524,31 @@ describe('active transcript refresh', () => { await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)) }) + it('pulls the open transcript once per (re)connect, not on session switches (#94779)', () => { + $changeEventsAvailable.set(true) + const refresh = vi.fn(async () => undefined) + + const { rerender } = renderSync(refresh, { gatewayState: 'connecting' }) + expect(refresh).not.toHaveBeenCalled() + + rerender({ gatewayState: 'open' }) + expect(refresh).toHaveBeenCalledTimes(1) + + rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'open' }) + expect(refresh).toHaveBeenCalledTimes(1) + + rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'closed' }) + rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'open' }) + expect(refresh).toHaveBeenCalledTimes(2) + }) + it('coalesces a burst of global session-change ticks', async () => { vi.useFakeTimers() $changeEventsAvailable.set(true) const refresh = vi.fn(async () => undefined) renderSync(refresh) + refresh.mockClear() // drop the connect-time pull; this test is about tick coalescing act(() => { for (let index = 0; index < 20; index += 1) { diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index ee803cfa3b..573e3b7d32 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -649,6 +649,19 @@ export function useBackgroundSync({ } }, [activeConnectionId, activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) + // Reconnect backstop (#94779): turns that finished while the socket was + // down never replay their sessions.changed tick, so the open transcript + // stayed stale until the user reopened it. Pull one signature-gated tail on + // every (re)connect — a no-change read costs nothing. Keyed on the + // connection, not the session, so a plain session switch adds no read; + // messaging transcripts already refresh on open in their own effect below. + useEffect(() => { + if (gatewayState === 'open' && !activeIsMessaging && activeSessionId && activeStoredSessionId) { + requestActiveTranscriptRefresh(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- connect-scoped: session deps would fire on every switch + }, [activeConnectionId, activeGatewayProfile, gatewayState]) + // A reconnect loses renderer-only working/attention atoms while the backend // keeps the actual turns alive. Re-seed from the gateway's in-memory session // registry immediately, then re-pull on every sessions.changed broadcast; a