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
This commit is contained in:
Teknium
2026-09-02 04:15:42 -07:00
parent a0e4ce252c
commit 6d061ede58
2 changed files with 57 additions and 15 deletions

View File

@@ -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<void>
}) {
const updateSessionState: Parameters<typeof useBackgroundSync>[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<void>,
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<void>, 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) {

View File

@@ -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