Skip to content

Commit bf473ab

Browse files
committed
fix(sdk): extract pendingToolCalls from raw partial before cleanupAbortedParts strips them
1 parent 84c5a22 commit bf473ab

3 files changed

Lines changed: 99 additions & 4 deletions

File tree

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,13 @@ type ReplaySessionOutTailResult<TUIMessage extends UIMessage> = {
510510
* the tail ended cleanly (every segment closed).
511511
*/
512512
partial: TUIMessage | undefined;
513+
/**
514+
* The trailing assistant message BEFORE `cleanupAbortedParts` ran. Same
515+
* `undefined` semantics as `partial`. Use this when you need to inspect
516+
* tool parts the cleanup would strip (e.g. `input-available` /
517+
* `input-streaming` orphans surfaced via `pendingToolCalls`).
518+
*/
519+
partialRaw: TUIMessage | undefined;
513520
};
514521

515522
type ReplaySessionOutTailImpl = <TUIMessage extends UIMessage>(
@@ -581,7 +588,7 @@ async function replaySessionOutTail<TUIMessage extends UIMessage>(
581588
if (type.startsWith("trigger:")) continue;
582589
collected.push(chunk as UIMessageChunk);
583590
}
584-
if (collected.length === 0) return { settled: [], partial: undefined };
591+
if (collected.length === 0) return { settled: [], partial: undefined, partialRaw: undefined };
585592

586593
// Split chunks into per-message segments. A `start` chunk demarcates the
587594
// beginning of an assistant message; chunks before any `start` (rare —
@@ -612,6 +619,7 @@ async function replaySessionOutTail<TUIMessage extends UIMessage>(
612619

613620
const settled: TUIMessage[] = [];
614621
let partial: TUIMessage | undefined;
622+
let partialRaw: TUIMessage | undefined;
615623
for (let i = 0; i < segments.length; i++) {
616624
const seg = segments[i]!;
617625
const isTrailing = i === segments.length - 1 && !seg.closed;
@@ -641,11 +649,16 @@ async function replaySessionOutTail<TUIMessage extends UIMessage>(
641649
const cleaned = cleanupAbortedParts(last as TUIMessage);
642650
if (cleaned.parts.length === 0) continue;
643651
partial = cleaned;
652+
// Keep the raw pre-cleanup message too — recovery boot extracts
653+
// `pendingToolCalls` from it, since `cleanupAbortedParts` strips
654+
// exactly the input-streaming / input-available tool parts that
655+
// we want to surface.
656+
partialRaw = last as TUIMessage;
644657
} else {
645658
settled.push(last as TUIMessage);
646659
}
647660
}
648-
return { settled, partial };
661+
return { settled, partial, partialRaw };
649662
}
650663

651664
/**
@@ -4972,6 +4985,7 @@ function chatAgent<
49724985
let bootSnapshot: ChatSnapshotV1<TUIMessage> | undefined;
49734986
let replayedSettled: TUIMessage[] = [];
49744987
let replayedPartial: TUIMessage | undefined;
4988+
let replayedPartialRaw: TUIMessage | undefined;
49754989
let replayedInTail: { message: TUIMessage; metadata: unknown; seqNum: number }[] = [];
49764990
// Wire payloads to dispatch as turns before the regular session.in
49774991
// pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
@@ -5036,6 +5050,7 @@ function chatAgent<
50365050
);
50375051
replayedSettled = replayResult.settled;
50385052
replayedPartial = replayResult.partial;
5053+
replayedPartialRaw = replayResult.partialRaw;
50395054
} catch (error) {
50405055
logger.warn(
50415056
"chat.agent: session.out replay failed; using snapshot only",
@@ -5160,7 +5175,11 @@ function chatAgent<
51605175
let hookRecoveredTurns: TUIMessage[] | undefined;
51615176
let hookBeforeBoot: (() => Promise<void>) | undefined;
51625177
if (couldHavePriorState && hasRecoveredState && onRecoveryBoot) {
5163-
const pendingToolCalls = extractPendingToolCallsFromPartial(partialAssistant);
5178+
// Extract from the RAW partial (pre-cleanup). `cleanupAbortedParts`
5179+
// strips exactly the input-streaming / input-available tool parts
5180+
// we want to surface here, so the cleaned `partialAssistant` would
5181+
// always report zero pending tool calls.
5182+
const pendingToolCalls = extractPendingToolCallsFromPartial(replayedPartialRaw);
51645183
const previousRunIdForHook = previousRunId ?? "";
51655184
let hookResult: RecoveryBootResult<TUIMessage> | void = undefined;
51665185
const { writer: hookWriter, flush: hookFlush } = createLazyChatWriter();

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,14 @@ export function mockChatAgent(
416416
seededReplayChunks.length === 0
417417
? []
418418
: ((await reduceChunksToMessages(seededReplayChunks)) as unknown[]);
419-
return { settled, partial: seededReplayPartial } as never;
419+
// For the mock harness, `partialRaw` is the same as `partial` — we
420+
// don't model cleanupAbortedParts separately. Recovery tests that
421+
// need a partialRaw distinct from partial install their own stub.
422+
return {
423+
settled,
424+
partial: seededReplayPartial,
425+
partialRaw: seededReplayPartial,
426+
} as never;
420427
});
421428

422429
// session.in tail override: each seeded UIMessage becomes a

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { mockChatAgent } from "../src/v3/test/index.js";
55
import { describe, expect, it, vi } from "vitest";
66
import { chat } from "../src/v3/ai.js";
77
import type { RecoveryBootEvent, RecoveryBootResult } from "../src/v3/ai.js";
8+
import { __setReplaySessionOutTailImplForTests } from "../src/v3/ai.js";
89
import { simulateReadableStream, streamText } from "ai";
910
import { MockLanguageModelV3 } from "ai/test";
1011
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
@@ -125,6 +126,74 @@ describe("onRecoveryBoot — chat.agent recovery hook", () => {
125126
}
126127
});
127128

129+
it("pendingToolCalls is extracted from the RAW partial (pre-cleanupAbortedParts)", async () => {
130+
// Real-world scenario: cancel-mid-tool-call. Session.out has tool-call
131+
// chunks but the tool never returned. cleanupAbortedParts strips the
132+
// input-available tool part from the partial used for the chain (you
133+
// don't want orphan tool calls poisoning the model context), but
134+
// `pendingToolCalls` should still surface what was happening.
135+
const cleanedPartial = {
136+
id: "a-orphan",
137+
role: "assistant" as const,
138+
parts: [{ type: "text" as const, text: "Let me look that up" }],
139+
};
140+
const rawPartial = {
141+
id: "a-orphan",
142+
role: "assistant" as const,
143+
parts: [
144+
{ type: "text" as const, text: "Let me look that up" },
145+
{
146+
type: "tool-search" as const,
147+
toolCallId: "tc-pending",
148+
state: "input-available" as const,
149+
input: { q: "vietnamese pho" },
150+
},
151+
],
152+
} as unknown as typeof cleanedPartial;
153+
154+
const captured: { event?: RecoveryBootEvent } = {};
155+
const model = new MockLanguageModelV3({
156+
doStream: async () => ({ stream: textStream("ok") }),
157+
});
158+
const u1 = userMessage("buffered", "u-1");
159+
const agent = chat.agent({
160+
id: "recovery-boot.pending-tool-from-raw",
161+
onRecoveryBoot: async (event) => {
162+
captured.event = event;
163+
return {};
164+
},
165+
run: async ({ messages, signal }) =>
166+
streamText({ model, messages, abortSignal: signal }),
167+
});
168+
const harness = mockChatAgent(agent, {
169+
chatId: "pending-tool-from-raw",
170+
continuation: true,
171+
previousRunId: "run_prior",
172+
});
173+
harness.seedSessionInTail([u1 as never]);
174+
// Install AFTER mockChatAgent — its constructor sets its own default
175+
// override that we want to replace for this test.
176+
__setReplaySessionOutTailImplForTests(async () =>
177+
({
178+
settled: [],
179+
partial: cleanedPartial,
180+
partialRaw: rawPartial,
181+
}) as never
182+
);
183+
try {
184+
await new Promise((r) => setTimeout(r, 50));
185+
expect(captured.event).toBeDefined();
186+
// Cleaned partial → chain (no input-available tool part)
187+
expect(captured.event!.partialAssistant?.parts).toHaveLength(1);
188+
// pendingToolCalls → from raw (input-available tool part visible)
189+
expect(captured.event!.pendingToolCalls).toHaveLength(1);
190+
expect(captured.event!.pendingToolCalls[0]!.toolCallId).toBe("tc-pending");
191+
expect(captured.event!.pendingToolCalls[0]!.toolName).toBe("search");
192+
} finally {
193+
await harness.close();
194+
}
195+
});
196+
128197
it("fires when there are in-flight users (no partial)", async () => {
129198
const captured: { event?: RecoveryBootEvent } = {};
130199
const model = new MockLanguageModelV3({

0 commit comments

Comments
 (0)