@@ -2319,7 +2319,11 @@ async function findSessionInReplayWindowEnd(
23192319 */
23202320async function installChatInputRouter(
23212321 chatId: string,
2322- options?: { fallbackResumeFrom?: number; recoveredThrough?: number; resuming?: boolean }
2322+ options?: {
2323+ fallbackResumeFrom?: number;
2324+ recoveredSeqNums?: readonly number[];
2325+ resuming?: boolean;
2326+ }
23232327): Promise<SessionChannelRouter> {
23242328 const entry = chatInputRouterEntry(chatId);
23252329 if (entry.attached) return entry.router;
@@ -2350,20 +2354,13 @@ async function installChatInputRouter(
23502354 }
23512355 }
23522356
2353- // A boot that replayed `.in` itself has already answered everything up to
2354- // `recoveredThrough`, so the floor has to cover it before the tail opens.
2355- if (options?.recoveredThrough !== undefined) {
2356- const recovered = options.recoveredThrough;
2357- checkpoint.resumeFrom = Math.max(checkpoint.resumeFrom ?? recovered, recovered);
2358- checkpoint.appliedThrough = Math.max(
2359- checkpoint.appliedThrough ?? checkpoint.resumeFrom,
2360- checkpoint.resumeFrom
2361- );
2362- }
2363-
23642357 const router = entry.router;
23652358 router.restore(checkpoint);
23662359
2360+ if (options?.recoveredSeqNums && options.recoveredSeqNums.length > 0) {
2361+ router.markRecovered(options.recoveredSeqNums);
2362+ }
2363+
23672364 const floor = router.resumeFrom();
23682365 if (floor !== undefined) {
23692366 sessionStreams.setLastSeqNum(chatId, "in", floor);
@@ -7271,6 +7268,19 @@ function chatAgent<
72717268 // `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
72727269 const bootInjectedQueue: ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>[] =
72737270 [];
7271+ const recoveredSeqByPayload = new WeakMap<
7272+ ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>,
7273+ number
7274+ >();
7275+ const dispatchBootInjected = (): ChatTaskWirePayload<
7276+ TUIMessage,
7277+ inferSchemaIn<TClientDataSchema>
7278+ > => {
7279+ const injected = bootInjectedQueue.shift()!;
7280+ const settledSeq = recoveredSeqByPayload.get(injected);
7281+ if (settledSeq !== undefined) chatInputRouter().settleRecovered(settledSeq);
7282+ return injected;
7283+ };
72747284 const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
72757285
72767286 // `.in` resume cursor, computed at most once per boot. The boot
@@ -7436,18 +7446,11 @@ function chatAgent<
74367446
74377447 // ── session.in router ──────────────────────────────────────────
74387448 //
7439- // Reads the turn boundary and subscribes in one call. `bootInCursor` is
7440- // only a fallback: the boot block above may already have resolved a
7441- // cursor from the snapshot, which is used when the boundary itself
7442- // carries none. Everything the boot replayed off `.in` is dispatched from
7443- // `bootInjectedQueue` below, so it goes into the floor here — folded in
7444- // after the subscription opens, the live tail re-delivers it as a turn.
7445- const lastRecoveredInSeq =
7446- replayedInTail.length > 0 ? replayedInTail[replayedInTail.length - 1]!.seqNum : undefined;
7449+ const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
74477450
74487451 await installChatInputRouter(payload.chatId, {
74497452 fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
7450- recoveredThrough: lastRecoveredInSeq ,
7453+ recoveredSeqNums ,
74517454 resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
74527455 });
74537456
@@ -7537,7 +7540,7 @@ function chatAgent<
75377540 // branches: at n=1 the orphan partial is dropped and the interrupted
75387541 // user is re-dispatched as a fresh turn instead.
75397542 let seedChain: TUIMessage[];
7540- let recoveredTurns: TUIMessage[];
7543+ let recoveredEntries: { message: TUIMessage; seqNum: number | undefined } [];
75417544 if (hookChain !== undefined) {
75427545 seedChain = hookChain;
75437546 } else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
@@ -7546,11 +7549,20 @@ function chatAgent<
75467549 seedChain = settledMessages;
75477550 }
75487551 if (hookRecoveredTurns !== undefined) {
7549- recoveredTurns = hookRecoveredTurns;
7552+ const seqNumByRecoveredId = new Map<string, number>();
7553+ for (const entry of replayedInTail) {
7554+ seqNumByRecoveredId.set(entry.message.id, entry.seqNum);
7555+ }
7556+ recoveredEntries = hookRecoveredTurns.map((message) => ({
7557+ message,
7558+ seqNum: seqNumByRecoveredId.get(message.id),
7559+ }));
75507560 } else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
7551- recoveredTurns = inFlightUsers.slice(1);
7561+ recoveredEntries = replayedInTail
7562+ .slice(1)
7563+ .map((r) => ({ message: r.message, seqNum: r.seqNum }));
75527564 } else {
7553- recoveredTurns = inFlightUsers ;
7565+ recoveredEntries = replayedInTail.map((r) => ({ message: r.message, seqNum: r.seqNum })) ;
75547566 }
75557567 // `beforeBoot` errors bubble — the customer opted into blocking
75567568 // persistence and a failure there should fail the run rather than
@@ -7581,12 +7593,13 @@ function chatAgent<
75817593 for (const entry of replayedInTail) {
75827594 metadataById.set(entry.message.id, entry.metadata);
75837595 }
7584- for (const msg of recoveredTurns) {
7596+ const dispatchedRecoveredSeqs = new Set<number>();
7597+ for (const { message: msg, seqNum } of recoveredEntries) {
75857598 if (wireMessageId && msg.id === wireMessageId) continue;
75867599 const recoveredMetadata = metadataById.has(msg.id)
75877600 ? metadataById.get(msg.id)
75887601 : payload.metadata;
7589- bootInjectedQueue.push( {
7602+ const injectedPayload = {
75907603 chatId: payload.chatId,
75917604 sessionId: payload.sessionId,
75927605 metadata: recoveredMetadata,
@@ -7595,7 +7608,17 @@ function chatAgent<
75957608 messageId: msg.id,
75967609 continuation: payload.continuation,
75977610 previousRunId: payload.previousRunId,
7598- } as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>);
7611+ } as ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>;
7612+ bootInjectedQueue.push(injectedPayload);
7613+ if (seqNum !== undefined) {
7614+ recoveredSeqByPayload.set(injectedPayload, seqNum);
7615+ dispatchedRecoveredSeqs.add(seqNum);
7616+ }
7617+ }
7618+ for (const entry of replayedInTail) {
7619+ if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
7620+ chatInputRouter().settleRecovered(entry.seqNum);
7621+ }
75997622 }
76007623
76017624 accumulatedUIMessages = seedChain;
@@ -7779,7 +7802,7 @@ function chatAgent<
77797802 */
77807803 let dispatchedRecoveredFirstTurn = false;
77817804 if (preloaded && bootInjectedQueue.length > 0) {
7782- currentWirePayload = bootInjectedQueue.shift()! ;
7805+ currentWirePayload = dispatchBootInjected() ;
77837806 dispatchedRecoveredFirstTurn = true;
77847807 }
77857808
@@ -8030,7 +8053,7 @@ function chatAgent<
80308053 // waiting on the live session.in. Subsequent recovered turns
80318054 // get drained by the end-of-turn picker below.
80328055 if (bootInjectedQueue.length > 0) {
8033- currentWirePayload = bootInjectedQueue.shift()! ;
8056+ currentWirePayload = dispatchBootInjected() ;
80348057 } else {
80358058 const effectiveIdleTimeout = idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds;
80368059 const effectiveTurnTimeout =
@@ -9611,7 +9634,7 @@ function chatAgent<
96119634 // produced these from in-flight user messages on session.in
96129635 // that the dead predecessor never acknowledged.
96139636 if (bootInjectedQueue.length > 0) {
9614- currentWirePayload = bootInjectedQueue.shift()! ;
9637+ currentWirePayload = dispatchBootInjected() ;
96159638 return "continue";
96169639 }
96179640
@@ -9987,7 +10010,7 @@ function chatAgent<
998710010 // recovered turn shouldn't strand the rest of the boot queue
998810011 // until an unrelated live message arrives.
998910012 if (bootInjectedQueue.length > 0) {
9990- currentWirePayload = bootInjectedQueue.shift()! ;
10013+ currentWirePayload = dispatchBootInjected() ;
999110014 continue;
999210015 }
999310016
0 commit comments