Skip to content

Commit 06b2622

Browse files
committed
fix(agents): preserve typed-block order and fall through on empty content arrays
1 parent 6a71aee commit 06b2622

2 files changed

Lines changed: 236 additions & 38 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,4 +2156,178 @@ describe("openai transport stream", () => {
21562156
expect(assembledText).toBe("Hello! How can I help?");
21572157
expect(assembledThinking).toBe("Let me reason: step one. Step two.");
21582158
});
2159+
2160+
it("preserves typed-block order when delta.content arrays interleave text and thinking", async () => {
2161+
// Locks in the Codex review concern: a delta.content array shaped
2162+
// `[{type:"text",…},{type:"thinking",…},{type:"text",…}]` must produce
2163+
// text → thinking → text blocks in that order, not coalesce into one
2164+
// text block followed by one thinking block.
2165+
const model = {
2166+
id: "mistral-small-latest",
2167+
name: "Mistral Small Latest",
2168+
api: "openai-completions",
2169+
provider: "mistral",
2170+
baseUrl: "https://api.mistral.ai/v1",
2171+
reasoning: true,
2172+
input: ["text"],
2173+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2174+
contextWindow: 128000,
2175+
maxTokens: 8192,
2176+
} satisfies Model<"openai-completions">;
2177+
2178+
const output = {
2179+
role: "assistant" as const,
2180+
content: [],
2181+
api: model.api,
2182+
provider: model.provider,
2183+
model: model.id,
2184+
usage: {
2185+
input: 0,
2186+
output: 0,
2187+
cacheRead: 0,
2188+
cacheWrite: 0,
2189+
totalTokens: 0,
2190+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2191+
},
2192+
stopReason: "stop",
2193+
timestamp: Date.now(),
2194+
};
2195+
2196+
const stream: { push(event: unknown): void } = { push() {} };
2197+
2198+
const mockChunks = [
2199+
{
2200+
id: "chatcmpl-mistral-order",
2201+
object: "chat.completion.chunk" as const,
2202+
choices: [
2203+
{
2204+
index: 0,
2205+
delta: {
2206+
content: [
2207+
{ type: "text", text: "intro." },
2208+
{ type: "thinking", thinking: "thought." },
2209+
{ type: "text", text: "outro." },
2210+
],
2211+
} as unknown,
2212+
logprobs: null,
2213+
finish_reason: null,
2214+
},
2215+
],
2216+
},
2217+
{
2218+
id: "chatcmpl-mistral-order",
2219+
object: "chat.completion.chunk" as const,
2220+
choices: [{ index: 0, delta: {}, logprobs: null, finish_reason: "stop" }],
2221+
},
2222+
] as const;
2223+
2224+
async function* mockStream() {
2225+
for (const chunk of mockChunks) {
2226+
yield chunk as never;
2227+
}
2228+
}
2229+
2230+
await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
2231+
2232+
expect(output.content.map((block) => (block as { type: string }).type)).toEqual([
2233+
"text",
2234+
"thinking",
2235+
"text",
2236+
]);
2237+
expect((output.content[0] as { text: string }).text).toBe("intro.");
2238+
expect((output.content[1] as { thinking: string }).thinking).toBe("thought.");
2239+
expect((output.content[2] as { text: string }).text).toBe("outro.");
2240+
});
2241+
2242+
it("falls through to reasoning_content when delta.content array yields no supported blocks", async () => {
2243+
// Locks in the Copilot review concern: an empty array (or an array of only
2244+
// unsupported block types) must NOT swallow the chunk via an unconditional
2245+
// `continue`. reasoning_content / tool_calls in the same chunk should still
2246+
// be processed.
2247+
const model = {
2248+
id: "mistral-small-latest",
2249+
name: "Mistral Small Latest",
2250+
api: "openai-completions",
2251+
provider: "mistral",
2252+
baseUrl: "https://api.mistral.ai/v1",
2253+
reasoning: true,
2254+
input: ["text"],
2255+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2256+
contextWindow: 128000,
2257+
maxTokens: 8192,
2258+
} satisfies Model<"openai-completions">;
2259+
2260+
const output = {
2261+
role: "assistant" as const,
2262+
content: [],
2263+
api: model.api,
2264+
provider: model.provider,
2265+
model: model.id,
2266+
usage: {
2267+
input: 0,
2268+
output: 0,
2269+
cacheRead: 0,
2270+
cacheWrite: 0,
2271+
totalTokens: 0,
2272+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2273+
},
2274+
stopReason: "stop",
2275+
timestamp: Date.now(),
2276+
};
2277+
2278+
const stream: { push(event: unknown): void } = { push() {} };
2279+
2280+
const mockChunks = [
2281+
{
2282+
id: "chatcmpl-mistral-fallthrough",
2283+
object: "chat.completion.chunk" as const,
2284+
choices: [
2285+
{
2286+
index: 0,
2287+
delta: {
2288+
content: [{ type: "unknown_block_type", payload: 1 }],
2289+
reasoning_content: "fallback reasoning",
2290+
} as Record<string, unknown>,
2291+
logprobs: null,
2292+
finish_reason: null,
2293+
},
2294+
],
2295+
},
2296+
{
2297+
id: "chatcmpl-mistral-fallthrough",
2298+
object: "chat.completion.chunk" as const,
2299+
choices: [
2300+
{
2301+
index: 0,
2302+
delta: { content: "answer." } as Record<string, unknown>,
2303+
logprobs: null,
2304+
finish_reason: null,
2305+
},
2306+
],
2307+
},
2308+
{
2309+
id: "chatcmpl-mistral-fallthrough",
2310+
object: "chat.completion.chunk" as const,
2311+
choices: [{ index: 0, delta: {}, logprobs: null, finish_reason: "stop" }],
2312+
},
2313+
] as const;
2314+
2315+
async function* mockStream() {
2316+
for (const chunk of mockChunks) {
2317+
yield chunk as never;
2318+
}
2319+
}
2320+
2321+
await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
2322+
2323+
const thinkingBlock = output.content.find(
2324+
(block) => (block as { type: string }).type === "thinking",
2325+
) as { thinking: string } | undefined;
2326+
const textBlock = output.content.find(
2327+
(block) => (block as { type: string }).type === "text",
2328+
) as { text: string } | undefined;
2329+
2330+
expect(thinkingBlock?.thinking).toBe("fallback reasoning");
2331+
expect(textBlock?.text).toBe("answer.");
2332+
});
21592333
});

src/agents/openai-transport-stream.ts

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,48 +1124,52 @@ async function processOpenAICompletionsStream(
11241124
// (`[{type:"thinking", thinking:"..."}, {type:"text", text:"..."}]`)
11251125
// instead of a string. JS string concatenation on the array (`"" + arr`)
11261126
// produced literal `"[object Object]"` tokens in the assembled text and a
1127-
// matching corrupted `text_delta` event. Unpack typed blocks into text +
1128-
// reasoning deltas, route reasoning blocks through the existing thinking
1129-
// append path, and only emit a text delta if real text content arrives.
1130-
// Plain string content keeps the original fast path.
1127+
// matching corrupted `text_delta` event. Walk the typed blocks in order
1128+
// and route each one through the existing text / thinking append paths
1129+
// so transcript block chronology and stream event order match the
1130+
// provider's original ordering. Plain string content keeps the original
1131+
// fast path. Unrecognized non-array shapes (including arrays whose blocks
1132+
// are all unsupported) fall through so reasoning_* and tool_calls in the
1133+
// same chunk are still processed.
11311134
const unpacked = unpackOpenAICompletionsContent(choice.delta.content);
1132-
if (unpacked.thinkingDelta.length > 0) {
1133-
const reasoningDelta = {
1134-
signature: "content_thinking",
1135-
text: unpacked.thinkingDelta,
1136-
};
1137-
if (currentBlock?.type === "toolCall") {
1138-
if (!pendingThinkingDelta) {
1139-
pendingThinkingDelta = { ...reasoningDelta };
1135+
for (const delta of unpacked.deltas) {
1136+
if (delta.kind === "thinking") {
1137+
const reasoningDelta = {
1138+
signature: "content_thinking",
1139+
text: delta.value,
1140+
};
1141+
if (currentBlock?.type === "toolCall") {
1142+
if (!pendingThinkingDelta) {
1143+
pendingThinkingDelta = { ...reasoningDelta };
1144+
} else {
1145+
pendingThinkingDelta.text += reasoningDelta.text;
1146+
}
11401147
} else {
1141-
pendingThinkingDelta.text += reasoningDelta.text;
1148+
appendThinkingDelta(reasoningDelta);
11421149
}
1143-
} else {
1144-
appendThinkingDelta(reasoningDelta);
1150+
continue;
11451151
}
1146-
}
1147-
if (unpacked.textDelta.length > 0) {
11481152
flushPendingThinkingDelta();
11491153
if (!currentBlock || currentBlock.type !== "text") {
11501154
finishCurrentBlock();
11511155
currentBlock = { type: "text", text: "" };
11521156
output.content.push(currentBlock);
11531157
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
11541158
}
1155-
currentBlock.text += unpacked.textDelta;
1159+
currentBlock.text += delta.value;
11561160
stream.push({
11571161
type: "text_delta",
11581162
contentIndex: blockIndex(),
1159-
delta: unpacked.textDelta,
1163+
delta: delta.value,
11601164
partial: output,
11611165
});
11621166
}
11631167
if (unpacked.recognized) {
11641168
continue;
11651169
}
1166-
// Unrecognized truthy non-string shape: fall through to the reasoning /
1167-
// tool_calls branches below rather than coercing the value into the
1168-
// assembled text.
1170+
// Unrecognized truthy non-string / no-supported-block shape: fall through
1171+
// to the reasoning / tool_calls branches below rather than coercing the
1172+
// value into the assembled text.
11691173
}
11701174
const reasoningDelta = getCompletionsReasoningDelta(choice.delta as Record<string, unknown>);
11711175
if (reasoningDelta) {
@@ -1233,53 +1237,73 @@ async function processOpenAICompletionsStream(
12331237
// (e.g. `[{type:"thinking", thinking:"..."}, {type:"text", text:"..."}]`,
12341238
// observed for Mistral with reasoning enabled where reasoning content
12351239
// arrives inside `delta.content` instead of a top-level reasoning field).
1236-
// `recognized` is true for both the string fast path and the typed-block array
1237-
// shape. When false (e.g. a plain object or unexpected primitive), the caller
1238-
// falls through to the reasoning/tool_calls branches instead of coercing the
1239-
// value into assembled text.
1240+
// Deltas are returned in the original block order (not coalesced by type) so a
1241+
// `[{type:"text",…},{type:"thinking",…}]` array does not silently flip into
1242+
// thinking-then-text on the consumer side.
1243+
// `recognized` is true for the string fast path and for arrays that yielded at
1244+
// least one supported typed block. Empty arrays or arrays whose blocks are all
1245+
// unsupported shapes return `recognized: false` so reasoning_* and tool_calls
1246+
// fields in the same chunk are still processed by the loop below.
1247+
type OpenAICompletionsContentDelta =
1248+
| { kind: "text"; value: string }
1249+
| { kind: "thinking"; value: string };
1250+
12401251
function unpackOpenAICompletionsContent(rawContent: unknown): {
1241-
textDelta: string;
1242-
thinkingDelta: string;
1252+
deltas: OpenAICompletionsContentDelta[];
12431253
recognized: boolean;
12441254
} {
12451255
if (typeof rawContent === "string") {
1246-
return { textDelta: rawContent, thinkingDelta: "", recognized: true };
1256+
return {
1257+
deltas: rawContent.length > 0 ? [{ kind: "text", value: rawContent }] : [],
1258+
recognized: true,
1259+
};
12471260
}
12481261
if (!Array.isArray(rawContent)) {
1249-
return { textDelta: "", thinkingDelta: "", recognized: false };
1262+
return { deltas: [], recognized: false };
12501263
}
1251-
let textDelta = "";
1252-
let thinkingDelta = "";
1264+
const deltas: OpenAICompletionsContentDelta[] = [];
1265+
let sawSupportedBlock = false;
12531266
for (const part of rawContent) {
12541267
if (!part || typeof part !== "object") {
12551268
continue;
12561269
}
12571270
const block = part as { type?: unknown; text?: unknown; thinking?: unknown };
12581271
if (block.type === "text" && typeof block.text === "string") {
1259-
textDelta += block.text;
1272+
sawSupportedBlock = true;
1273+
if (block.text.length > 0) {
1274+
deltas.push({ kind: "text", value: block.text });
1275+
}
12601276
continue;
12611277
}
12621278
if (block.type === "thinking") {
12631279
// Mistral reasoning blocks observed in two shapes: `.thinking` as a
12641280
// string, or `.thinking` as a nested array of `{type:"text", text}` parts.
12651281
if (typeof block.thinking === "string") {
1266-
thinkingDelta += block.thinking;
1282+
sawSupportedBlock = true;
1283+
if (block.thinking.length > 0) {
1284+
deltas.push({ kind: "thinking", value: block.thinking });
1285+
}
12671286
continue;
12681287
}
12691288
if (Array.isArray(block.thinking)) {
1289+
let thinkingValue = "";
12701290
for (const sub of block.thinking) {
12711291
if (!sub || typeof sub !== "object") {
12721292
continue;
12731293
}
1274-
const subText = (sub as { text?: unknown }).text;
1275-
if (typeof subText === "string") {
1276-
thinkingDelta += subText;
1294+
const subBlock = sub as { type?: unknown; text?: unknown };
1295+
if (subBlock.type === "text" && typeof subBlock.text === "string") {
1296+
thinkingValue += subBlock.text;
12771297
}
12781298
}
1299+
sawSupportedBlock = true;
1300+
if (thinkingValue.length > 0) {
1301+
deltas.push({ kind: "thinking", value: thinkingValue });
1302+
}
12791303
}
12801304
}
12811305
}
1282-
return { textDelta, thinkingDelta, recognized: true };
1306+
return { deltas, recognized: sawSupportedBlock };
12831307
}
12841308

12851309
function getCompletionsReasoningDelta(delta: Record<string, unknown>): {

0 commit comments

Comments
 (0)