diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index c584e4a3aa..d9a69b7913 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -2196,6 +2196,69 @@ function completionOrder(sink: { frames: SubscriptionFrame[] }): string[] { ); } +test('a failed transcript page records the underlying cause before the generic outcome', async () => { + const message = assistantMessage('界'.repeat(20_000)); + const baseReader = transcriptReader([message]); + const reader: SessionTranscriptReader = { + ...baseReader, + // The bootstrap request carries no position; a page always does. Throw only + // for the page so the subscription still opens. + readDurablePage: async (sessionId, request, project) => { + if (request.position !== undefined) throw new Error('injected oversized Turn'); + return baseReader.readDurablePage(sessionId, request, project); + }, + }; + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => canonical(), + new SessionAdmissionGate(), + undefined, + reader, + ); + attachTestConnection(coordinator, 'connection-page-failure', new RecordingSink()); + const opened = await open(coordinator, 'connection-page-failure', { + kind: 'tail', + maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + }); + const transcript = opened.transcript; + const cursor = transcript?.durable.nextCursor; + assert.ok(transcript); + assert.ok(cursor); + if (!transcript || !cursor) { + coordinator.close(); + return; + } + + // The generic outcome carries no cause, so the Host has to log one instead. + const logged: string[] = []; + const originalConsoleError = console.error; + console.error = (...values: unknown[]) => logged.push(values.map(String).join(' ')); + try { + const outcome = await coordinator.handlers['session.transcript.page']( + { + subscriptionId: opened.subscriptionId, + direction: 'older', + throughSequence: transcript.durable.throughSequence, + cursor, + anchorSequence: null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }, + connectionContext('connection-page-failure'), + ); + assert.deepEqual(outcome, { + ok: false, + error: { code: 'persistence_failed', message: 'Session transcript is unavailable' }, + }); + } finally { + console.error = originalConsoleError; + coordinator.close(); + } + + assert.equal(logged.length, 1); + assert.match(logged[0] ?? '', /session\.transcript\.page failed/); + assert.match(logged[0] ?? '', /injected oversized Turn/); +}); + function textCompleteEvent(messageId: string, text: string) { return { type: 'text_complete' as const, diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 4e10ff14cf..47964130bc 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -61,6 +61,7 @@ import type { SessionContinuityOperationHandlerMap, } from './operation-dispatcher.js'; import type { RuntimeHostAccessAuthority } from './access-authority.js'; +import { boundedFailureDiagnostic } from './failure-diagnostic.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; import { type CanonicalSessionProjection, @@ -1122,6 +1123,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { error: { code: 'invalid_request', message: error.message }, }; } + // The client can only retry, but a transcript page that failed for any + // other reason is a Host-side defect: the generic outcome the caller + // receives carries none of the cause, so record it here or it is lost. + console.error( + `[runtime-host] session.transcript.page failed: ${boundedFailureDiagnostic(error)}`, + ); return { ok: false, error: { code: 'persistence_failed', message: 'Session transcript is unavailable' },