Skip to content

Commit f35012e

Browse files
committed
Ignore streaming AI SDK tool availability
1 parent 85bfd41 commit f35012e

2 files changed

Lines changed: 117 additions & 3 deletions

File tree

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,25 @@ describe("AI SDK conversion utilities", () => {
484484
})
485485
})
486486

487+
it("uses args when tool-call input is an empty object", () => {
488+
const part = {
489+
type: "tool-call" as const,
490+
toolCallId: "call_1",
491+
toolName: "attempt_completion",
492+
input: {},
493+
args: { result: "done" },
494+
}
495+
const chunks = [...processAiSdkStreamPart(part as any)]
496+
497+
expect(chunks).toHaveLength(1)
498+
expect(chunks[0]).toEqual({
499+
type: "tool_call",
500+
id: "call_1",
501+
name: "attempt_completion",
502+
arguments: '{"result":"done"}',
503+
})
504+
})
505+
487506
it("processes complete tool-call chunks with arguments", () => {
488507
const part = {
489508
type: "tool-call" as const,
@@ -502,7 +521,7 @@ describe("AI SDK conversion utilities", () => {
502521
})
503522
})
504523

505-
it("emits the complete tool-call chunk after streaming input chunks", () => {
524+
it("emits complete tool-input-available after streaming input chunks", () => {
506525
const seenStreamingToolCallIds = new Set<string>()
507526
const chunks = [
508527
...processAiSdkStreamPart(
@@ -516,7 +535,7 @@ describe("AI SDK conversion utilities", () => {
516535
...processAiSdkStreamPart({ type: "tool-input-end" as const, id: "call_1" }, seenStreamingToolCallIds),
517536
...processAiSdkStreamPart(
518537
{
519-
type: "tool-call" as const,
538+
type: "tool-input-available" as const,
520539
toolCallId: "call_1",
521540
toolName: "attempt_completion",
522541
input: { result: "done" },
@@ -534,6 +553,78 @@ describe("AI SDK conversion utilities", () => {
534553
},
535554
])
536555
expect(seenStreamingToolCallIds.has("call_1")).toBe(false)
556+
expect(seenStreamingToolCallIds.has("completed:call_1")).toBe(true)
557+
})
558+
559+
it("ignores duplicate tool-call chunks after complete tool-input-available chunks", () => {
560+
const seenStreamingToolCallIds = new Set<string>()
561+
const chunks = [
562+
...processAiSdkStreamPart(
563+
{
564+
type: "tool-input-available" as const,
565+
toolCallId: "call_1",
566+
toolName: "attempt_completion",
567+
input: { result: "done" },
568+
},
569+
seenStreamingToolCallIds,
570+
),
571+
...processAiSdkStreamPart(
572+
{
573+
type: "tool-call" as const,
574+
toolCallId: "call_1",
575+
toolName: "attempt_completion",
576+
input: { result: "done" },
577+
},
578+
seenStreamingToolCallIds,
579+
),
580+
]
581+
582+
expect(chunks).toEqual([
583+
{
584+
type: "tool_call",
585+
id: "call_1",
586+
name: "attempt_completion",
587+
arguments: '{"result":"done"}',
588+
},
589+
])
590+
expect(seenStreamingToolCallIds.has("completed:call_1")).toBe(false)
591+
})
592+
593+
it("ignores tool-input-available for calls that are still streaming input", () => {
594+
const seenStreamingToolCallIds = new Set<string>()
595+
const chunks = [
596+
...processAiSdkStreamPart(
597+
{ type: "tool-input-start" as const, id: "call_1", toolName: "attempt_completion" },
598+
seenStreamingToolCallIds,
599+
),
600+
...processAiSdkStreamPart(
601+
{
602+
type: "tool-input-available" as const,
603+
toolCallId: "call_1",
604+
toolName: "attempt_completion",
605+
input: {},
606+
},
607+
seenStreamingToolCallIds,
608+
),
609+
...processAiSdkStreamPart(
610+
{
611+
type: "tool-call" as const,
612+
toolCallId: "call_1",
613+
toolName: "attempt_completion",
614+
input: { result: "done" },
615+
},
616+
seenStreamingToolCallIds,
617+
),
618+
]
619+
620+
expect(chunks).toEqual([
621+
{
622+
type: "tool_call",
623+
id: "call_1",
624+
name: "attempt_completion",
625+
arguments: '{"result":"done"}',
626+
},
627+
])
537628
})
538629

539630
it("processes source chunks with URL", () => {

src/api/transform/ai-sdk.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ type ExtendedStreamPart =
191191

192192
function stringifyToolInput(part: Extract<TextStreamPart<any>, { type: "tool-call" }>) {
193193
const input =
194-
"input" in part && part.input !== undefined
194+
"input" in part && part.input !== undefined && !isEmptyObject(part.input)
195195
? part.input
196196
: "args" in part
197197
? (part as { args: unknown }).args
@@ -202,6 +202,14 @@ function stringifyToolInput(part: Extract<TextStreamPart<any>, { type: "tool-cal
202202
return typeof input === "string" ? input : JSON.stringify(input)
203203
}
204204

205+
function isEmptyObject(value: unknown) {
206+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0
207+
}
208+
209+
function hasToolInput(part: Pick<ToolInputPart, "input">) {
210+
return part.input !== undefined && !isEmptyObject(part.input)
211+
}
212+
205213
type ToolInputPart = {
206214
id?: string
207215
toolCallId?: string
@@ -215,6 +223,10 @@ function getToolCallId(part: ToolInputPart) {
215223
return part.toolCallId ?? part.id ?? ""
216224
}
217225

226+
function completedToolCallMarker(toolCallId: string) {
227+
return `completed:${toolCallId}`
228+
}
229+
218230
/**
219231
* Process a single AI SDK stream part and yield the appropriate ApiStreamChunk(s).
220232
* This generator handles all TextStreamPart types and converts them to the
@@ -249,6 +261,12 @@ export function* processAiSdkStreamPart(
249261
break
250262

251263
case "tool-input-available":
264+
if (seenStreamingToolCallIds?.has(getToolCallId(part)) && !hasToolInput(part)) {
265+
break
266+
}
267+
268+
seenStreamingToolCallIds?.delete(getToolCallId(part))
269+
seenStreamingToolCallIds?.add(completedToolCallMarker(getToolCallId(part)))
252270
yield {
253271
type: "tool_call",
254272
id: getToolCallId(part),
@@ -258,6 +276,11 @@ export function* processAiSdkStreamPart(
258276
break
259277

260278
case "tool-call":
279+
if (seenStreamingToolCallIds?.has(completedToolCallMarker(part.toolCallId))) {
280+
seenStreamingToolCallIds.delete(completedToolCallMarker(part.toolCallId))
281+
break
282+
}
283+
261284
seenStreamingToolCallIds?.delete(part.toolCallId)
262285
// Complete tool call - emit for compatibility
263286
yield {

0 commit comments

Comments
 (0)