Skip to content

Commit 98f2903

Browse files
committed
fix(sdk): onRecoveryBoot fires only when a partial assistant is present
1 parent bf473ab commit 98f2903

2 files changed

Lines changed: 32 additions & 19 deletions

File tree

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5168,8 +5168,12 @@ function chatAgent<
51685168
);
51695169
const inFlightUsers = replayedInTail.map((r) => r.message);
51705170
const partialAssistant = replayedPartial;
5171-
const hasRecoveredState =
5172-
partialAssistant !== undefined || inFlightUsers.length > 0;
5171+
// Fire the hook only when there's a partial assistant — the
5172+
// mid-stream-died signal. In-flight users alone (no partial)
5173+
// cover graceful exits like `chat.requestUpgrade()` where the
5174+
// predecessor chose to end before processing the message;
5175+
// those route through the normal continuation-wait path.
5176+
const hasRecoveredState = partialAssistant !== undefined;
51735177

51745178
let hookChain: TUIMessage[] | undefined;
51755179
let hookRecoveredTurns: TUIMessage[] | undefined;

packages/trigger-sdk/test/recovery-boot.test.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -194,34 +194,31 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
194194
}
195195
});
196196

197-
it("fires when there are in-flight users (no partial)", async () => {
198-
const captured: { event?: RecoveryBootEvent } = {};
197+
it("does NOT fire when there are in-flight users but no partial (graceful exit path)", async () => {
198+
// chat.requestUpgrade(), chat.endRun() before processing, and similar
199+
// graceful exits leave an unacknowledged user on session.in but no
200+
// partial assistant on session.out. That's not recovery — the next
201+
// run just dispatches the message normally.
202+
const onRecoveryBoot = vi.fn();
199203
const model = new MockLanguageModelV3({
200204
doStream: async () => ({ stream: textStream("ok") }),
201205
});
202206
const u1 = userMessage("buffered while dead", "u-buffered");
203207
const agent = chat.agent({
204-
id: "recovery-boot.inflight-users",
205-
onRecoveryBoot: async (event) => {
206-
captured.event = event;
207-
return {};
208-
},
208+
id: "recovery-boot.inflight-users-no-partial",
209+
onRecoveryBoot,
209210
run: async ({ messages, signal }) =>
210211
streamText({ model, messages, abortSignal: signal }),
211212
});
212213
const harness = mockChatAgent(agent, {
213-
chatId: "inflight-users",
214+
chatId: "inflight-users-no-partial",
214215
continuation: true,
215216
previousRunId: "run_prior",
216217
});
217218
harness.seedSessionInTail([u1 as never]);
218219
try {
219220
await new Promise((r) => setTimeout(r, 50));
220-
expect(captured.event).toBeDefined();
221-
expect(captured.event!.inFlightUsers).toHaveLength(1);
222-
expect(captured.event!.inFlightUsers[0]!.id).toBe("u-buffered");
223-
expect(captured.event!.partialAssistant).toBeUndefined();
224-
expect(captured.event!.pendingToolCalls).toEqual([]);
221+
expect(onRecoveryBoot).not.toHaveBeenCalled();
225222
} finally {
226223
await harness.close();
227224
}
@@ -316,6 +313,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
316313
return { stream: textStream(`reply ${turnCount}`) };
317314
},
318315
});
316+
const partial = assistantMessage("partial answer", "a-partial");
319317
const u1 = userMessage("buffered", "u-1");
320318
const agent = chat.agent({
321319
id: "recovery-boot.suppress-dispatch",
@@ -328,6 +326,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
328326
continuation: true,
329327
previousRunId: "run_prior",
330328
});
329+
harness.seedSessionOutPartial(partial as never);
331330
harness.seedSessionInTail([u1 as never]);
332331
try {
333332
// No turn should fire from the boot-injected queue.
@@ -345,6 +344,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
345344
doStream: async () => ({ stream: textStream("acked") }),
346345
});
347346
const custom = assistantMessage("custom-recovered-history", "a-custom");
347+
const partial = assistantMessage("partial", "a-partial");
348348
const u1 = userMessage("buffered", "u-1");
349349
let observedMessageCount = 0;
350350
const agent = chat.agent({
@@ -364,6 +364,7 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
364364
continuation: true,
365365
previousRunId: "run_prior",
366366
});
367+
harness.seedSessionOutPartial(partial as never);
367368
harness.seedSessionInTail([u1 as never]);
368369
try {
369370
await new Promise((r) => setTimeout(r, 50));
@@ -412,7 +413,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
412413
return { stream: textStream("ok") };
413414
},
414415
});
415-
const u1 = userMessage("buffered", "u-1");
416+
const partial = assistantMessage("partial", "a-partial");
417+
const u1 = userMessage("buffered original", "u-1");
418+
const u2 = userMessage("followup", "u-2");
416419
const agent = chat.agent({
417420
id: "recovery-boot.before-boot",
418421
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({
@@ -428,7 +431,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
428431
continuation: true,
429432
previousRunId: "run_prior",
430433
});
431-
harness.seedSessionInTail([u1 as never]);
434+
harness.seedSessionOutPartial(partial as never);
435+
// Two users — smart default consumes u1 into the chain, leaves u2 for dispatch
436+
harness.seedSessionInTail([u1 as never, u2 as never]);
432437
try {
433438
await new Promise((r) => setTimeout(r, 50));
434439
expect(order).toEqual(["beforeBoot", "turn"]);
@@ -445,7 +450,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
445450
return { stream: textStream("ok") };
446451
},
447452
});
448-
const u1 = userMessage("buffered", "u-1");
453+
const partial = assistantMessage("partial", "a-partial");
454+
const u1 = userMessage("buffered original", "u-1");
455+
const u2 = userMessage("followup", "u-2");
449456
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
450457
const agent = chat.agent({
451458
id: "recovery-boot.hook-throws",
@@ -460,7 +467,9 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
460467
continuation: true,
461468
previousRunId: "run_prior",
462469
});
463-
harness.seedSessionInTail([u1 as never]);
470+
harness.seedSessionOutPartial(partial as never);
471+
// Two users so smart default leaves u2 to dispatch (u1 spliced into chain)
472+
harness.seedSessionInTail([u1 as never, u2 as never]);
464473
try {
465474
await new Promise((r) => setTimeout(r, 100));
466475
// Default behavior: the in-flight user is re-dispatched as a turn

0 commit comments

Comments
 (0)