From b300a0cfb3feef066e7136140aaebdf0dc9042fd Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 2 Aug 2026 03:12:38 -0400 Subject: [PATCH] fix(agents): cap DeepSeek DSML recovery buffer at 256 KB (#117175) Co-authored-by: Sebastien Tardif <1413412+SebTardif@users.noreply.github.com> --- .../openai-completions-transport.ts | 230 +++++++++- ...nsport-stream.deepseek-and-shaping.test.ts | 396 ++++++++++++++++++ 2 files changed, 607 insertions(+), 19 deletions(-) diff --git a/packages/ai/src/transports/openai-completions-transport.ts b/packages/ai/src/transports/openai-completions-transport.ts index cf7019e13e35..908ff50be9b3 100644 --- a/packages/ai/src/transports/openai-completions-transport.ts +++ b/packages/ai/src/transports/openai-completions-transport.ts @@ -881,63 +881,153 @@ const DEEPSEEK_DSML_TOOL_OPEN_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) => const DEEPSEEK_DSML_TOOL_CLOSE_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) => DEEPSEEK_DSML_TOOL_KINDS.map((kind) => ``), ); +const DEEPSEEK_DSML_INVOKE_OPEN_PREFIXES = DEEPSEEK_DSML_BARS.map( + (bar) => `<${bar}DSML${bar}invoke`, +); +const DEEPSEEK_DSML_INVOKE_CLOSE_TOKENS = DEEPSEEK_DSML_BARS.map( + (bar) => ``, +); const DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN = Math.max( ...DEEPSEEK_DSML_TOOL_OPEN_TOKENS.map((token) => token.length), ); +const DEEPSEEK_DSML_RECOVERY_MAX_BOUNDARY_LEN = Math.max( + ...DEEPSEEK_DSML_TOOL_OPEN_TOKENS.map((token) => token.length), + ...DEEPSEEK_DSML_TOOL_CLOSE_TOKENS.map((token) => token.length), + ...DEEPSEEK_DSML_INVOKE_OPEN_PREFIXES.map((token) => token.length), + ...DEEPSEEK_DSML_INVOKE_CLOSE_TOKENS.map((token) => token.length), +); + +// Match MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES / MAX_POST_TOOL_CALL_BUFFER_BYTES. +const MAX_DSML_RECOVERY_BUFFER_BYTES = 256_000; +const DEEPSEEK_DSML_SCAN_BATCH_CHARS = 64 * 1_024; + +type DeepSeekDsmlToolBlockScanState = { + offset: number; + mode: "outer" | "invoke-open" | "invoke-body"; + invokeOpenStart: number; +}; function createDeepSeekDsmlToolCallRecoverer() { let buffer = ""; + let bufferBytes = 0; + let bufferEndsWithHighSurrogate = false; + let pendingScanChars = 0; + let activeOpenToken: string | null = null; + let blockScanState: DeepSeekDsmlToolBlockScanState = { + offset: 0, + mode: "outer", + invokeOpenStart: -1, + }; + const resetBlockScan = () => { + activeOpenToken = null; + pendingScanChars = 0; + blockScanState = { offset: 0, mode: "outer", invokeOpenStart: -1 }; + }; const consume = (final: boolean): DeepSeekDsmlRecoveredPart[] => { const output: DeepSeekDsmlRecoveredPart[] = []; while (buffer) { - const open = findEarliestStringToken(buffer, DEEPSEEK_DSML_TOOL_OPEN_TOKENS); + const open = activeOpenToken + ? { index: 0, token: activeOpenToken } + : findEarliestStringToken(buffer, DEEPSEEK_DSML_TOOL_OPEN_TOKENS); if (!open) { + resetBlockScan(); if (final) { output.push({ kind: "text", text: buffer }); buffer = ""; + bufferBytes = 0; + bufferEndsWithHighSurrogate = false; return output; } const keep = longestDeepSeekDsmlToolOpenPrefixSuffixLength(buffer); const emitLength = buffer.length - keep; if (emitLength > 0) { - output.push({ kind: "text", text: buffer.slice(0, emitLength) }); - buffer = buffer.slice(emitLength); + const emitted = buffer.slice(0, emitLength); + output.push({ kind: "text", text: emitted }); + bufferBytes -= Buffer.byteLength(emitted, "utf8"); + buffer = buffer.slice(emitted.length); + if (!buffer) { + bufferEndsWithHighSurrogate = false; + } } return output; } if (open.index > 0) { - output.push({ kind: "text", text: buffer.slice(0, open.index) }); - buffer = buffer.slice(open.index); + const prefix = buffer.slice(0, open.index); + output.push({ kind: "text", text: prefix }); + bufferBytes -= Buffer.byteLength(prefix, "utf8"); + buffer = buffer.slice(prefix.length); + resetBlockScan(); } - const afterOpen = buffer.slice(open.token.length); - const close = findEarliestStringToken(afterOpen, DEEPSEEK_DSML_TOOL_CLOSE_TOKENS); + activeOpenToken = open.token; + if (blockScanState.offset === 0) { + blockScanState.offset = open.token.length; + } + const blockScan = scanDeepSeekDsmlToolBlock( + buffer, + open.token.replace("<", " MAX_DSML_RECOVERY_BUFFER_BYTES) { + throw new Error("Exceeded DeepSeek DSML recovery buffer limit"); } return output; } - const body = afterOpen.slice(0, close.index); - const blockLength = open.token.length + close.index + close.token.length; + resetBlockScan(); + const body = buffer.slice(open.token.length, close.index); + const blockText = buffer.slice(0, close.index + close.token.length); + const blockBytes = Buffer.byteLength(blockText, "utf8"); + if (blockBytes > MAX_DSML_RECOVERY_BUFFER_BYTES) { + throw new Error("Exceeded DeepSeek DSML recovery buffer limit"); + } const recoveredToolCalls = parseDeepSeekDsmlToolCallBlock(body); if (recoveredToolCalls.length > 0) { output.push(...recoveredToolCalls); } else { - output.push({ kind: "text", text: buffer.slice(0, blockLength) }); + output.push({ kind: "text", text: blockText }); + } + bufferBytes -= Buffer.byteLength(blockText, "utf8"); + buffer = buffer.slice(blockText.length); + if (!buffer) { + bufferEndsWithHighSurrogate = false; } - buffer = buffer.slice(blockLength); } return output; }; return { push(chunk: string) { + const append = utf8ByteLengthForAppend(bufferEndsWithHighSurrogate, chunk); + bufferBytes += append.bytes; + bufferEndsWithHighSurrogate = append.endsWithHighSurrogate; buffer += chunk; + pendingScanChars += chunk.length; + if ( + activeOpenToken && + pendingScanChars < DEEPSEEK_DSML_SCAN_BATCH_CHARS && + !chunk.includes("<") && + !chunk.includes(">") && + bufferBytes <= MAX_DSML_RECOVERY_BUFFER_BYTES + ) { + return []; + } + pendingScanChars = 0; return consume(false); }, flush() { @@ -948,23 +1038,23 @@ function createDeepSeekDsmlToolCallRecoverer() { function parseDeepSeekDsmlToolCallBlock(body: string): RecoveredDeepSeekDsmlToolCall[] { const toolCalls: RecoveredDeepSeekDsmlToolCall[] = []; - const invokeOpenRegex = /<[||]DSML[||]invoke\b([^>]*)>/g; + const invokeOpenRegex = /<[||]DSML[||]invoke\b([^<>]*)>/g; let openMatch: RegExpExecArray | null; while ((openMatch = invokeOpenRegex.exec(body)) !== null) { - const invokeName = parseXmlAttribute(openMatch[1] ?? "", "name"); - if (!invokeName) { - continue; - } const invokeBodyStart = openMatch.index + openMatch[0].length; const invokeClose = findEarliestStringToken(body.slice(invokeBodyStart), [ "", "", ]); if (!invokeClose) { - continue; + break; } const invokeBody = body.slice(invokeBodyStart, invokeBodyStart + invokeClose.index); invokeOpenRegex.lastIndex = invokeBodyStart + invokeClose.index + invokeClose.token.length; + const invokeName = parseXmlAttribute(openMatch[1] ?? "", "name"); + if (!invokeName) { + continue; + } const parsedArguments = parseDeepSeekDsmlInvokeArguments(invokeBody); if (!parsedArguments) { continue; @@ -1043,10 +1133,10 @@ function decodeDeepSeekDsmlText(value: string): string { .replaceAll("&", "&"); } -function findEarliestStringToken(text: string, tokens: readonly string[]) { +function findEarliestStringToken(text: string, tokens: readonly string[], fromIndex = 0) { let best: { index: number; token: string } | null = null; for (const token of tokens) { - const index = text.indexOf(token); + const index = text.indexOf(token, fromIndex); if (index !== -1 && (!best || index < best.index)) { best = { index, token }; } @@ -1054,6 +1144,108 @@ function findEarliestStringToken(text: string, tokens: readonly string[]) { return best; } +function scanDeepSeekDsmlToolBlock( + text: string, + closeToken: string, + contentStartIndex: number, + state: DeepSeekDsmlToolBlockScanState, +): + | { kind: "close"; index: number; token: string } + | { kind: "nested-open"; index: number; token: string } + | { kind: "incomplete" } { + while (state.offset < text.length) { + if (state.mode === "invoke-open") { + const nextOpen = text.indexOf("<", state.offset); + const nextClose = text.indexOf(">", state.offset); + if (nextClose === -1 && nextOpen === -1) { + state.offset = text.length; + return { kind: "incomplete" }; + } + if (nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose)) { + state.mode = "outer"; + state.offset = nextOpen; + state.invokeOpenStart = -1; + continue; + } + const invokeOpenTag = text.slice(state.invokeOpenStart, nextClose + 1); + if (!/^<[||]DSML[||]invoke\b[^<>]*>$/.test(invokeOpenTag)) { + state.mode = "outer"; + state.offset = state.invokeOpenStart + 1; + state.invokeOpenStart = -1; + continue; + } + state.mode = "invoke-body"; + state.offset = nextClose + 1; + state.invokeOpenStart = -1; + continue; + } + + if (state.mode === "invoke-body") { + const invokeClose = findEarliestStringToken( + text, + DEEPSEEK_DSML_INVOKE_CLOSE_TOKENS, + state.offset, + ); + if (!invokeClose) { + state.offset = Math.max(0, text.length - DEEPSEEK_DSML_RECOVERY_MAX_BOUNDARY_LEN + 1); + return { kind: "incomplete" }; + } + state.mode = "outer"; + state.offset = invokeClose.index + invokeClose.token.length; + continue; + } + + const toolOpen = findEarliestStringToken(text, DEEPSEEK_DSML_TOOL_OPEN_TOKENS, state.offset); + const toolCloseIndex = text.indexOf(closeToken, state.offset); + const invokeOpen = findEarliestStringToken( + text, + DEEPSEEK_DSML_INVOKE_OPEN_PREFIXES, + state.offset, + ); + const next = [ + toolOpen ? { kind: "nested-open" as const, ...toolOpen } : null, + toolCloseIndex === -1 + ? null + : { kind: "close" as const, index: toolCloseIndex, token: closeToken }, + invokeOpen ? { kind: "invoke-open" as const, ...invokeOpen } : null, + ] + .filter((candidate) => candidate !== null) + .toSorted((left, right) => left.index - right.index)[0]; + if (!next) { + state.offset = Math.max( + contentStartIndex, + text.length - DEEPSEEK_DSML_RECOVERY_MAX_BOUNDARY_LEN + 1, + ); + return { kind: "incomplete" }; + } + if (next.kind === "invoke-open") { + state.mode = "invoke-open"; + state.invokeOpenStart = next.index; + state.offset = next.index + next.token.length; + continue; + } + return next; + } + return { kind: "incomplete" }; +} + +function utf8ByteLengthForAppend(bufferEndsWithHighSurrogate: boolean, chunk: string) { + let bytes = Buffer.byteLength(chunk, "utf8"); + if (!chunk) { + return { bytes, endsWithHighSurrogate: bufferEndsWithHighSurrogate }; + } + const nextCodeUnit = chunk.charCodeAt(0); + if (bufferEndsWithHighSurrogate && nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) { + // Each isolated surrogate counts as three UTF-8 bytes; the joined scalar is four. + bytes -= 2; + } + const finalCodeUnit = chunk.charCodeAt(chunk.length - 1); + return { + bytes, + endsWithHighSurrogate: finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff, + }; +} + function longestDeepSeekDsmlToolOpenPrefixSuffixLength(text: string) { const maxLength = Math.min(text.length, DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN - 1); for (let length = maxLength; length > 0; length -= 1) { diff --git a/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts b/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts index 40fc8273eb6d..1e2b887c353f 100644 --- a/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts +++ b/src/agents/openai-transport-stream.deepseek-and-shaping.test.ts @@ -275,6 +275,402 @@ describe("openai transport stream", () => { expect(JSON.stringify(events)).not.toContain("DSML"); }); + it("rejects an oversized DeepSeek DSML block when the crossing chunk contains its close", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const events: CapturedStreamEvent[] = []; + + const prefix = '<|DSML|tool_calls><|DSML|invoke name="read">{"path":"'; + const suffix = '"}'; + const padding = "x".repeat(256_001 - Buffer.byteLength(prefix + suffix, "utf8")); + const content = prefix + padding + suffix + " after"; + expect(Buffer.byteLength(prefix + padding + suffix, "utf8")).toBe(256_001); + const chunks = Array.from({ length: Math.ceil(content.length / 4096) }, (_, index) => + content.slice(index * 4096, (index + 1) * 4096), + ); + + await expect( + testing.processOpenAICompletionsStream( + streamChunks( + chunks.map((contentChunk, index) => + makeCompletionsChunk( + { content: contentChunk }, + index === chunks.length - 1 ? "stop" : null, + ), + ), + ), + output, + model, + { push: (event) => events.push(event as CapturedStreamEvent) }, + ), + ).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit"); + expect(events.filter((event) => event.type?.startsWith("toolcall_"))).toEqual([]); + }); + + it("rejects an oversized DeepSeek DSML recovery buffer using UTF-8 bytes", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + + // 200k "é": .length under 256k string units, UTF-8 bytes over 256k. + const multibyteBody = + '<|DSML|invoke name="session_status"><|DSML|parameter name="key" string="true">' + + "\u00E9".repeat(200_000) + + ""; + + await expect( + testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk( + { + content: "<|DSML|tool_calls>" + multibyteBody, + }, + "stop", + ), + ]), + output, + model, + { push() {} }, + ), + ).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit"); + }); + + it("counts split surrogate pairs exactly at the DeepSeek DSML recovery cap", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const prefix = '<|DSML|tool_calls><|DSML|invoke name="read">{"path":"'; + const suffix = '"}'; + const outerClose = ""; + const emoji = "\u{1f600}"; + const padding = "x".repeat( + 256_000 - Buffer.byteLength(prefix + emoji + suffix + outerClose, "utf8"), + ); + const beforeSplit = prefix + padding + "\uD83D"; + const afterSplit = "\uDE00" + suffix; + expect(Buffer.byteLength(beforeSplit + afterSplit + outerClose, "utf8")).toBe(256_000); + + await testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk({ content: beforeSplit }), + makeCompletionsChunk({ content: afterSplit }), + makeCompletionsChunk({ content: outerClose }, "stop"), + ]), + output, + model, + { push() {} }, + ); + + expect(output.stopReason).toBe("toolUse"); + expect(output.content).toEqual([ + { + type: "toolCall", + id: expect.stringMatching(/^call_[0-9a-f]{24}$/), + name: "read", + arguments: { path: padding + emoji }, + }, + ]); + }); + + it("surfaces an oversized DeepSeek DSML recovery buffer as a transport error", async () => { + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + for (const chunk of [ + makeCompletionsChunk({ + content: "<|DSML|tool_calls>" + "x".repeat(300_000), + }), + makeCompletionsChunk({}, "stop"), + ]) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end("data: [DONE]\n\n"); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Missing loopback server address"); + } + const model = makeCompletionsModel({ + ...createDeepSeekCompletionsModel(), + baseUrl: `http://127.0.0.1:${address.port}/v1`, + }); + const stream = createOpenAICompletionsTransportStreamFn()( + model, + { + systemPrompt: "system", + messages: [{ role: "user", content: "Read the file", timestamp: Date.now() }], + tools: [], + } as never, + { apiKey: "test-key" } as never, + ); + + const events: Array<{ + type: string; + reason?: string; + error?: { errorMessage?: string; content?: unknown[] }; + }> = []; + for await (const event of stream as AsyncIterable<(typeof events)[number]>) { + events.push(event); + } + + expect(events).toContainEqual( + expect.objectContaining({ + type: "error", + reason: "error", + error: expect.objectContaining({ + errorMessage: "Exceeded DeepSeek DSML recovery buffer limit", + content: [], + }), + }), + ); + expect(events.filter((event) => event.type === "toolcall_start")).toEqual([]); + expect(events.filter((event) => event.type === "toolcall_delta")).toEqual([]); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it("fails before a later DSML call after overflow can be authorized", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const laterCall = + '<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/repro.md"}'; + + await expect( + testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk({ + content: "<|DSML|tool_calls>" + "x".repeat(256_001), + }), + makeCompletionsChunk({ + content: " after " + laterCall, + }), + makeCompletionsChunk({}, "tool_calls"), + ]), + output, + model, + { push() {} }, + ), + ).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit"); + }); + + it("does not carry surrogate accounting across emitted visible text", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + + await expect( + testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk({ content: "\ud83d" }), + makeCompletionsChunk({ content: "\ude00" }), + makeCompletionsChunk({ + content: "<|DSML|tool_calls>" + "x".repeat(256_001), + }), + makeCompletionsChunk({}, "stop"), + ]), + output, + model, + { push() {} }, + ), + ).rejects.toThrow("Exceeded DeepSeek DSML recovery buffer limit"); + }); + + it("rejects a nested DSML wrapper before the original outer close", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const events: CapturedStreamEvent[] = []; + + await expect( + testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk({ content: "<|DSML|tool_calls><|DSML|tool_" }), + makeCompletionsChunk({ + content: + 'calls><|DSML|invoke name="read">{"path":"/tmp/nested.md"}', + }), + makeCompletionsChunk({ content: "" }, "stop"), + ]), + output, + model, + { + push(event) { + events.push(event as CapturedStreamEvent); + }, + }, + ), + ).rejects.toThrow("Nested DeepSeek DSML recovery wrappers are not supported"); + expect(events.filter((event) => event.type?.startsWith("toolcall_") === true)).toEqual([]); + expect(output.content.some((part) => part.type === "toolCall")).toBe(false); + }); + + it("does not accept a different DSML wrapper kind as the outer close", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + + await testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk({ + content: + '<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/mismatch.md"}', + }), + makeCompletionsChunk({}, "stop"), + ]), + output, + model, + { push() {} }, + ); + + expect(output.stopReason).toBe("stop"); + expect(output.content.some((part) => part.type === "toolCall")).toBe(false); + }); + + it("does not rewind across the outer opener after a short first body chunk", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + + await testing.processOpenAICompletionsStream( + streamChunks([ + makeCompletionsChunk({ content: "<|DSML|tool_calls>\n" }), + makeCompletionsChunk({ + content: + '<|DSML|invoke name="read">{"path":"/tmp/fragmented.md"}', + }), + makeCompletionsChunk({}, "stop"), + ]), + output, + model, + { push() {} }, + ); + + expect(output.stopReason).toBe("toolUse"); + expect(output.content).toEqual([ + { + type: "toolCall", + id: expect.stringMatching(/^call_[0-9a-f]{24}$/), + name: "read", + arguments: { path: "/tmp/fragmented.md" }, + }, + ]); + }); + + it("treats DSML-looking text inside a parameter value as payload", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const content = + '<|DSML|tool_calls><|DSML|invoke name="message">' + + '<|DSML|parameter name="text" string="true">' + + "literal <|DSML|tool_calls> marker" + + ""; + + const chunks = Array.from(content, (char) => makeCompletionsChunk({ content: char })); + chunks.push(makeCompletionsChunk({}, "stop")); + await testing.processOpenAICompletionsStream(streamChunks(chunks), output, model, { + push() {}, + }); + + expect(output.stopReason).toBe("toolUse"); + expect(output.content).toEqual([ + { + type: "toolCall", + id: expect.stringMatching(/^call_[0-9a-f]{24}$/), + name: "message", + arguments: { text: "literal <|DSML|tool_calls> marker" }, + }, + ]); + }); + + it("ignores an incomplete invoke-like prefix before a valid invoke", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const content = + "<|DSML|tool_calls>literal <|DSML|invoke marker " + + '<|DSML|invoke name="read">{"path":"/tmp/valid.md"}' + + ""; + + await testing.processOpenAICompletionsStream( + streamChunks( + Array.from(content, (char) => makeCompletionsChunk({ content: char })).concat([ + makeCompletionsChunk({}, "stop"), + ]), + ), + output, + model, + { push() {} }, + ); + + expect(output.stopReason).toBe("toolUse"); + expect(output.content).toEqual([ + { + type: "toolCall", + id: expect.stringMatching(/^call_[0-9a-f]{24}$/), + name: "read", + arguments: { path: "/tmp/valid.md" }, + }, + ]); + }); + + it("does not recover a nested call hidden inside a nameless invoke", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const events: CapturedStreamEvent[] = []; + const content = + "<|DSML|tool_calls><|DSML|invoke><|DSML|tool_calls>" + + '<|DSML|invoke name="read">{"path":"/tmp/bypass"}' + + ""; + + await testing.processOpenAICompletionsStream( + streamChunks([makeCompletionsChunk({ content }), makeCompletionsChunk({}, "stop")]), + output, + model, + { + push(event) { + events.push(event as CapturedStreamEvent); + }, + }, + ); + + expect(output.stopReason).toBe("stop"); + expect(events.filter((event) => event.type?.startsWith("toolcall_") === true)).toEqual([]); + expect(output.content.some((part) => part.type === "toolCall")).toBe(false); + }); + + it("treats DSML-looking text inside JSON arguments as payload", async () => { + const model = createDeepSeekCompletionsModel(); + const output = createAssistantOutput(model); + const content = + '<|DSML|tool_calls><|DSML|invoke name="message">' + + '{"text":"literal <|DSML|tool_calls> marker"}' + + ""; + + await testing.processOpenAICompletionsStream( + streamChunks([makeCompletionsChunk({ content }, "stop")]), + output, + model, + { push() {} }, + ); + + expect(output.stopReason).toBe("toolUse"); + expect(output.content).toEqual([ + { + type: "toolCall", + id: expect.stringMatching(/^call_[0-9a-f]{24}$/), + name: "message", + arguments: { text: "literal <|DSML|tool_calls> marker" }, + }, + ]); + }); + it.each([ { finishReason: "length", stopReason: "length" }, { finishReason: "content_filter", stopReason: "error" },