Skip to content

Commit f03adfb

Browse files
ericallamclaude
andcommitted
fix(sdk,core): keep continuation-boot recovery green under the monotonic session.in floor
A snapshot with no savedAt no longer drops the whole transcript: savedAt is optional and defaults to 0 (it only orders snapshot history before live chunks, so losing it should not lose the conversation). The offline chat harness now models session.in as a durable stream whose seqNums stay monotonic across a chat's runs, and the test session-stream manager's setLastSeqNum only ever advances (matching the production manager). Without this a fresh per-run manager restarted seqNums at 0, so a continuation's follow-up message collided with the resume floor and was dropped, hanging the turn loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VG39FXXkFFU24U5EtJMwPi
1 parent a075eb1 commit f03adfb

3 files changed

Lines changed: 37 additions & 6 deletions

File tree

packages/core/src/v3/sessionStreams/chatSnapshot.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export type ChatSnapshotV1<TUIMessage extends UIMessage = UIMessage> = {
4444
*/
4545
export const ChatSnapshotV1Schema = z.object({
4646
version: z.literal(1),
47-
savedAt: z.number(),
47+
savedAt: z.number().optional(),
4848
messages: z.array(z.unknown()),
4949
lastOutEventId: z.string().optional(),
5050
lastInEventId: z.string().optional(),
@@ -81,7 +81,7 @@ export type TranscriptSnapshotV2<TUIMessage extends UIMessage = UIMessage> = {
8181

8282
export const TranscriptSnapshotV2Schema = z.object({
8383
version: z.literal(2),
84-
savedAt: z.number(),
84+
savedAt: z.number().optional(),
8585
messages: z.array(
8686
z.object({
8787
id: z.string(),
@@ -102,6 +102,9 @@ export const TranscriptSnapshotV2Schema = z.object({
102102
* `message` are dropped; a version 2 entry whose `message.id` disagrees with
103103
* the envelope `id` is dropped too, since a reader keys by one and renders by
104104
* the other. A caller never sees an entry it would crash on or mis-order.
105+
* A missing `savedAt` defaults to `0` rather than rejecting the whole blob:
106+
* the field only orders snapshot history before live chunks, and dropping a
107+
* whole conversation over an absent timestamp is the wrong failure mode.
105108
* Returns `undefined` for an unknown version or a body that is not a
106109
* snapshot; callers treat that as "no snapshot".
107110
*/
@@ -119,7 +122,7 @@ export function parseTranscriptSnapshot<TUIMessage extends UIMessage = UIMessage
119122
}
120123
return {
121124
version: 2,
122-
savedAt: v2.data.savedAt,
125+
savedAt: v2.data.savedAt ?? 0,
123126
messages,
124127
state: v2.data.state ?? null,
125128
lastOutEventId: v2.data.lastOutEventId,
@@ -137,7 +140,7 @@ export function parseTranscriptSnapshot<TUIMessage extends UIMessage = UIMessage
137140
}
138141
return {
139142
version: 2,
140-
savedAt: v1.data.savedAt,
143+
savedAt: v1.data.savedAt ?? 0,
141144
messages,
142145
state: null,
143146
lastOutEventId: v1.data.lastOutEventId,

packages/core/src/v3/test/test-session-stream-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,11 @@ export class TestSessionStreamManager implements SessionStreamManager {
220220
}
221221

222222
setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void {
223-
this.seqNums.set(keyFor(sessionId, io), seqNum);
223+
const key = keyFor(sessionId, io);
224+
const current = this.seqNums.get(key);
225+
if (current === undefined || seqNum > current) {
226+
this.seqNums.set(key, seqNum);
227+
}
224228
}
225229

226230
consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void {

packages/trigger-sdk/src/v3/test/mock-chat-agent.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,16 @@ function isControlChunk(chunk: unknown): boolean {
335335
return typeof type === "string" && CONTROL_CHUNK_TYPES.has(type);
336336
}
337337

338+
/**
339+
* Highest `session.in` seqNum any harness has produced for a session id,
340+
* keyed by `sessionId`. Production `session.in` is a durable S2 stream whose
341+
* seqNums are monotonic across the runs of a chat; a fresh in-memory manager
342+
* per `mockChatAgent` would otherwise restart at 0, so a continuation's
343+
* follow-up message would collide with the resume floor and be dropped. This
344+
* survives the per-run manager reset so continuation runs stay monotonic.
345+
*/
346+
const durableSessionInSeq = new Map<string, number>();
347+
338348
/**
339349
* Create an offline test harness for a `chat.agent` task.
340350
*
@@ -574,7 +584,21 @@ export function mockChatAgent(
574584
...(options.headStartMessages ? { headStartMessages: options.headStartMessages } : {}),
575585
};
576586

577-
sendSessionInput = drivers.sessions.in.send;
587+
const durableSeq = durableSessionInSeq.get(sessionId);
588+
if (durableSeq !== undefined) {
589+
sessionStreams.setLastSeqNum(sessionId, "in", durableSeq);
590+
}
591+
const rawSendSessionInput = drivers.sessions.in.send;
592+
sendSessionInput = async (id, data, io, metadata) => {
593+
await rawSendSessionInput(id, data, io, metadata);
594+
const io2 = io ?? "in";
595+
if (io2 === "in") {
596+
const latest = sessionStreams.lastSeqNum(id, "in");
597+
if (latest !== undefined) {
598+
durableSessionInSeq.set(id, Math.max(durableSessionInSeq.get(id) ?? latest, latest));
599+
}
600+
}
601+
};
578602
closeSessionInput = drivers.sessions.in.close;
579603

580604
// Record every chunk written to session.out, detect turn-complete.

0 commit comments

Comments
 (0)