Skip to content

Commit 99df0d7

Browse files
committed
fix: resolve 6 Responses API schema conformance bugs
- item_reference dropped: synthesize assistant message for orphaned function_call_output - annotations missing: add annotations: [] to all output_text content items - item_id missing on reasoning_summary_part.added, .done, and reasoning_summary_text.done - web_search_call action missing type: "search" in streaming events and output prefix - item_reference for assistant text messages not counted in assistantCount - multi-fco assistantCount inflation: backward scan to append to existing assistant messages
1 parent ba845c9 commit 99df0d7

1 file changed

Lines changed: 92 additions & 10 deletions

File tree

src/responses.ts

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ function extractTextContent(content: string | ResponsesContentPart[] | undefined
8686

8787
export function responsesInputToMessages(req: ResponsesRequest): ChatMessage[] {
8888
const messages: ChatMessage[] = [];
89+
// Track item_reference placeholders so we can upgrade or clean them up
90+
const itemReferencePlaceholders = new WeakSet<ChatMessage>();
8991

9092
// instructions field → system message
9193
if (req.instructions) {
@@ -120,15 +122,85 @@ export function responsesInputToMessages(req: ResponsesRequest): ChatMessage[] {
120122
],
121123
});
122124
} else if (item.type === "function_call_output") {
125+
// Bug 1 fix: If there's no preceding assistant message with a matching
126+
// tool_call for this call_id, synthesize one. This happens when the AI SDK
127+
// sends [user, item_reference, function_call_output] — the item_reference
128+
// placeholder (see below) has no tool_calls, so we need a real assistant
129+
// message with the tool_call for turnIndex counting.
130+
const hasMatchingToolCall = messages.some(
131+
(m) => m.role === "assistant" && m.tool_calls?.some((tc) => tc.id === item.call_id),
132+
);
133+
if (!hasMatchingToolCall) {
134+
// Check if the last message is an item_reference placeholder — if so,
135+
// upgrade it to carry the tool_call instead of synthesizing a duplicate.
136+
const lastMsg = messages[messages.length - 1];
137+
if (
138+
lastMsg &&
139+
lastMsg.role === "assistant" &&
140+
itemReferencePlaceholders.has(lastMsg) &&
141+
!lastMsg.tool_calls
142+
) {
143+
lastMsg.content = null;
144+
lastMsg.tool_calls = [
145+
{
146+
id: item.call_id ?? generateToolCallId(),
147+
type: "function",
148+
function: { name: "", arguments: "" },
149+
},
150+
];
151+
itemReferencePlaceholders.delete(lastMsg);
152+
} else {
153+
// Multi-fco case: look for a recent assistant with tool_calls that
154+
// belongs to the same turn. After the first fco upgrades a placeholder,
155+
// subsequent fco's see [assistant(call_A), tool(call_A)] — the last
156+
// assistant with tool_calls (right before the trailing tool messages)
157+
// is the correct target.
158+
let appended = false;
159+
for (let k = messages.length - 1; k >= 0; k--) {
160+
const m = messages[k];
161+
if (m.role === "assistant" && m.tool_calls) {
162+
m.tool_calls.push({
163+
id: item.call_id ?? generateToolCallId(),
164+
type: "function",
165+
function: { name: "", arguments: "" },
166+
});
167+
appended = true;
168+
break;
169+
}
170+
// Stop scanning if we hit a user message — different turn
171+
if (m.role === "user") break;
172+
}
173+
if (!appended) {
174+
messages.push({
175+
role: "assistant",
176+
content: null,
177+
tool_calls: [
178+
{
179+
id: item.call_id ?? generateToolCallId(),
180+
type: "function",
181+
function: { name: "", arguments: "" },
182+
},
183+
],
184+
});
185+
}
186+
}
187+
}
123188
messages.push({
124189
role: "tool",
125190
content: item.output ?? "",
126191
tool_call_id: item.call_id,
127192
});
193+
} else if (item.type === "item_reference") {
194+
// Bug 6 fix: item_reference items represent prior assistant turns (text
195+
// or function_call). Push a placeholder so they count in assistantCount.
196+
// If a subsequent function_call_output arrives, the handler above will
197+
// upgrade this placeholder to carry tool_calls (avoiding double-count).
198+
const placeholder: ChatMessage = { role: "assistant", content: "" };
199+
itemReferencePlaceholders.add(placeholder);
200+
messages.push(placeholder);
128201
} else {
129-
// Skip item_reference, local_shell_call, mcp_list_tools, etc. — not needed
130-
// for fixture matching. Logging is not threaded into this pure conversion
131-
// function; callers can inspect the returned messages if needed.
202+
// Skip local_shell_call, mcp_list_tools, etc. — not needed for fixture
203+
// matching.
132204
}
133205
}
134206

@@ -370,6 +442,7 @@ function buildReasoningStreamEvents(
370442

371443
events.push({
372444
type: "response.reasoning_summary_part.added",
445+
item_id: reasoningId,
373446
output_index: 0,
374447
summary_index: 0,
375448
part: { type: "summary_text", text: "" },
@@ -388,13 +461,15 @@ function buildReasoningStreamEvents(
388461

389462
events.push({
390463
type: "response.reasoning_summary_text.done",
464+
item_id: reasoningId,
391465
output_index: 0,
392466
summary_index: 0,
393467
text: reasoning,
394468
});
395469

396470
events.push({
397471
type: "response.reasoning_summary_part.done",
472+
item_id: reasoningId,
398473
output_index: 0,
399474
summary_index: 0,
400475
part: { type: "summary_text", text: reasoning },
@@ -430,7 +505,7 @@ function buildWebSearchStreamEvents(
430505
type: "web_search_call",
431506
id: searchId,
432507
status: "in_progress",
433-
action: { query: queries[i] },
508+
action: { type: "search", query: queries[i] },
434509
},
435510
});
436511

@@ -441,7 +516,7 @@ function buildWebSearchStreamEvents(
441516
type: "web_search_call",
442517
id: searchId,
443518
status: "completed",
444-
action: { query: queries[i] },
519+
action: { type: "search", query: queries[i] },
445520
},
446521
});
447522
}
@@ -545,7 +620,7 @@ function buildMessageOutputEvents(
545620
type: "response.content_part.added",
546621
output_index: outputIndex,
547622
content_index: 0,
548-
part: { type: "output_text", text: "" },
623+
part: { type: "output_text", text: "", annotations: [] },
549624
});
550625

551626
for (let i = 0; i < content.length; i += chunkSize) {
@@ -568,15 +643,15 @@ function buildMessageOutputEvents(
568643
type: "response.content_part.done",
569644
output_index: outputIndex,
570645
content_index: 0,
571-
part: { type: "output_text", text: content },
646+
part: { type: "output_text", text: content, annotations: [] },
572647
});
573648

574649
const msgItem = {
575650
type: "message",
576651
id: msgId,
577652
status: "completed",
578653
role: "assistant",
579-
content: [{ type: "output_text", text: content }],
654+
content: [{ type: "output_text", text: content, annotations: [] }],
580655
};
581656

582657
events.push({ type: "response.output_item.done", output_index: outputIndex, item: msgItem });
@@ -603,7 +678,7 @@ function buildOutputPrefix(content: string, reasoning?: string, webSearches?: st
603678
type: "web_search_call",
604679
id: generateId("ws"),
605680
status: "completed",
606-
action: { query },
681+
action: { type: "search", query },
607682
});
608683
}
609684
}
@@ -613,7 +688,7 @@ function buildOutputPrefix(content: string, reasoning?: string, webSearches?: st
613688
id: itemId(),
614689
status: "completed",
615690
role: "assistant",
616-
content: [{ type: "output_text", text: content }],
691+
content: [{ type: "output_text", text: content, annotations: [] }],
617692
});
618693

619694
return output;
@@ -869,7 +944,14 @@ export async function handleResponses(
869944
);
870945

871946
if (fixture) {
947+
defaults.logger.debug(
948+
`Responses fixture matched for ${req.method ?? "POST"} ${req.url ?? "/v1/responses"}`,
949+
);
872950
journal.incrementFixtureMatchCount(fixture, fixtures, testId);
951+
} else {
952+
defaults.logger.debug(
953+
`No responses fixture matched for ${req.method ?? "POST"} ${req.url ?? "/v1/responses"}`,
954+
);
873955
}
874956

875957
if (

0 commit comments

Comments
 (0)