fix(sse): deepseek-web resilience — premature session close + malformed tool-call recovery (#13226)

* fix(sse): deepseek-web collectSSEContent no longer returns a silent partial stub on premature session close

collectSSEContent() (used for the deepseek-web tool-calling / non-stream path)
drained the upstream SSE body and returned whatever content it had once the
reader reported done, with no check that DeepSeek had actually signalled
completion via response/status: "FINISHED".

When the upstream cookie session drops mid-generation (expired session,
anti-bot challenge, network interruption), the HTTP body simply closes
early. That was indistinguishable from a real completion: execute()
returned HTTP 200 with finish_reason "stop" and whatever partial stub text
had arrived so far. Observed in production call logs: a lone "I'll check
that..." / "Vou verificar..." with no continuation, reported as a
successful completion.

collectSSEContent now tracks whether the FINISHED status event was seen. If
the stream ends without it, it throws instead of returning the stub -
execute()'s existing try/catch turns that into a proper 502 that the
client, or a combo's retry/fallback logic, can react to.

Added tests/unit/deepseek-web-premature-close.test.ts covering both the
premature-close error path and the normal FINISHED completion path. Full
deepseek-web unit suite (97 tests) still passes.

* fix(sse): recover malformed deepseek-web tool-call replies and retry when unrecoverable

Two related failure modes on the deepseek-web tool-calling path, both
observed in production call logs from real agentic (VS Code Copilot-style)
usage of the deepseek combo:

1. DeepSeek's web session occasionally leaks malformed/internal formatting
   tokens right after an otherwise-complete <tool>{json} body, instead of
   a clean </tool> close (observed: a fully valid create_file JSON call
   immediately followed by corrupted pseudo-tags). parseLooseJsonObject's
   strict JSON.parse rejected the whole block over that trailing garbage,
   even though a perfectly valid object sat at the start - so the call was
   silently dropped and the raw tagged text was shown to the user instead
   of the file being created.

   deepseekWebTools.ts: added salvageLeadingJsonObject(), a quote/escape
   aware balanced-brace scanner that recovers just the leading JSON object
   when the strict parse fails, reusing the same salvage idea already used
   elsewhere in this file (findBareJsonCandidates) for bare-JSON detection.

2. When even that salvage cannot recover a call (genuinely truncated JSON,
   garbled beyond repair), execute() previously gave up on the first try.
   Since this is a scraped, non-deterministic web session rather than a
   real API, simply asking again is usually enough to get a clean reply.

   deepseek-web.ts: the hasTools branch now detects an unparsed <tool...>
   tag surviving in the cleaned content and retries with a brand-new
   session, bounded to MAX_TOOL_PARSE_ATTEMPTS (2) - never an unbounded
   retry loop, and a reply that parses cleanly on the first try costs no
   extra latency.

Builds on the collectSSEContent premature-close fix from the same PR -
that one covers the upstream session dropping mid-stream; this one covers
the session completing but returning malformed tool-call content.

Testing:
- tests/unit/deepseek-web-tools-salvage-leading-json.test.ts (4 tests):
  recovery from the exact production-observed corruption pattern, escaped
  quotes/nested braces before the garbage, correct non-promotion of
  genuinely truncated JSON, and no regression on well-formed blocks.
- tests/unit/deepseek-web-tool-call-retry.test.ts (3 tests): retry
  succeeds on a fresh session, retry is bounded (gives up after
  MAX_TOOL_PARSE_ATTEMPTS and surfaces the raw content rather than
  looping forever), and a clean first reply never triggers a retry.
- Full deepseek-web unit suite: 104/104 passing, no regressions.
- The salvage fix was additionally verified directly against the exact
  malformed content captured from a live production call log (not just
  the hand-written test fixture).

---------

Co-authored-by: VictorRP7 <187780317+VictorRP7@users.noreply.github.com>
This commit is contained in:
VictorRP7
2026-09-19 00:03:34 -03:00
committed by GitHub
parent db022768df
commit 8288a4a012
5 changed files with 524 additions and 8 deletions

View File

@@ -377,6 +377,12 @@ async function collectSSEContent(
let content = "";
let reasoningContent = "";
let currentPath: "thinking" | "content" | "" = "";
// Track whether DeepSeek actually signalled completion (`response/status: "FINISHED"`).
// Without this, an upstream session drop (expired cookie, anti-bot challenge, network
// hiccup) mid-stream was silently reported as a normal "stop" completion with whatever
// partial content had arrived so far — e.g. just "I'll check that..." with no follow-up,
// HTTP 200, finish_reason "stop". Confirmed in production call logs.
let sawFinished = false;
const streamModel = model || "deepseek-web";
const thinkingModel = isThinkingModel(streamModel);
const searchResults: DeepSeekSearchResult[] = [];
@@ -423,6 +429,8 @@ async function collectSSEContent(
const p = data?.p;
const v = data?.v;
if (p === "response/status" && v === "FINISHED") sawFinished = true;
if (v && typeof v === "object" && v.response) {
if (v.response.thinking_enabled === true) currentPath = "thinking";
else if (v.response.thinking_enabled === false) currentPath = "content";
@@ -484,6 +492,18 @@ async function collectSSEContent(
const citations = appendSearchCitations(searchResults, streamModel);
if (citations) content += `\n\n${citations}`;
// The upstream HTTP body closed without ever sending `response/status: "FINISHED"`.
// That means the DeepSeek web session was cut off mid-generation (expired cookie,
// anti-bot challenge, network drop, etc.) rather than genuinely completing. Surface
// this as an error (caught by execute()'s try/catch -> 502) instead of returning the
// partial stub as a successful "stop" response.
if (!sawFinished) {
throw new Error(
"DeepSeek web session ended before completion (no FINISHED signal received) — " +
"likely a dropped cookie session or network interruption upstream. Retry the request."
);
}
return { content, reasoningContent };
}
@@ -1079,13 +1099,49 @@ export class DeepSeekWebExecutor extends BaseExecutor {
// OpenAI tool_calls. Buffering (even for stream clients) is acceptable because
// tool invocations are short and need the complete block to parse. (#2820)
if (hasTools) {
const { content, reasoningContent } = await collectSSEContent(resp.body!, clientModel);
// The scraped web session occasionally returns a malformed reply where DeepSeek
// clearly attempted a tool call (a literal <tool...> tag is present) but the block
// could not be parsed even with salvageLeadingJsonObject's recovery (genuinely
// truncated JSON, garbled beyond repair, etc). Unlike a real API, this upstream is
// non-deterministic enough that simply asking again with a fresh session usually
// succeeds — so retry a bounded number of times before giving up and surfacing the
// raw (still-tagged) text to the caller.
const MAX_TOOL_PARSE_ATTEMPTS = 2;
let content = "";
let reasoningContent = "";
let cleanedContent = "";
let toolCalls: ReturnType<typeof parseDeepSeekToolCalls>["toolCalls"] = null;
for (let attempt = 1; attempt <= MAX_TOOL_PARSE_ATTEMPTS; attempt += 1) {
({ content, reasoningContent } = await collectSSEContent(resp.body!, clientModel));
({ content: cleanedContent, toolCalls } = parseDeepSeekToolCalls(
content,
`call-${Date.now()}`,
requestedTools
));
const unparsedToolTagRemains =
!toolCalls && /<tool(?:_call)?[\s:>]/i.test(cleanedContent);
if (!unparsedToolTagRemains || attempt === MAX_TOOL_PARSE_ATTEMPTS) break;
log?.warn?.(
"DEEPSEEK-WEB",
`Malformed tool-call reply on attempt ${attempt}/${MAX_TOOL_PARSE_ATTEMPTS} — retrying with a fresh session`
);
if (persistSession) sessionCache.delete(userToken);
sessionId = await createSession(accessToken, signal);
if (persistSession) {
evictOldest(sessionCache);
sessionCache.set(userToken, { sessionId, createdAt: Date.now() });
}
const retried = await performCompletion(sessionId);
resp = retried.resp;
reqHeaders = retried.reqHeaders;
requestPayload = retried.requestPayload;
if (!resp.ok) break; // fall through — final content/toolCalls stay from the last successful attempt
}
await cleanupFn();
const { content: cleanedContent, toolCalls } = parseDeepSeekToolCalls(
content,
`call-${Date.now()}`,
requestedTools
);
return buildToolAwareResult({
stream: stream !== false,
clientModel,

View File

@@ -325,6 +325,47 @@ function buildSchemaParamMap(requestedTools: unknown): Map<string, Set<string>>
return map;
}
// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right
// after an otherwise-complete JSON tool call body (observed in production: a valid
// `{"name": ..., "arguments": {...}}` object immediately followed by corrupted
// pseudo-tags instead of a clean `</tool>` close). `parseLooseJsonObject` uses a strict
// `JSON.parse`, which rejects the whole string over that trailing garbage even though a
// perfectly valid object sits right at the start. This scans for the first balanced
// `{...}` object (quote/escape aware) and returns just that slice, so it can still be
// parsed on its own.
function salvageLeadingJsonObject(text: string): string | null {
const start = text.indexOf("{");
if (start === -1) return null;
let depth = 0;
let quote: '"' | "'" | "" = "";
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (ch === "\\") escaped = true;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") {
quote = ch as '"' | "'";
continue;
}
if (ch === "{") {
depth += 1;
continue;
}
if (ch === "}") {
depth -= 1;
if (depth === 0) return text.slice(start, i + 1);
}
}
return null; // never balanced — genuinely truncated, nothing to salvage
}
/**
* Turn one tool block (tag name + inner text) into a name + JSON-string arguments.
* Returns null when no plausible tool name can be recovered.
@@ -342,7 +383,13 @@ function extractCall(
const paramObj = argsChild ? null : buildArgsFromParameters(inner);
const hasXmlChildren = !!nameChild || !!argsChild || !!paramObj;
const json = hasXmlChildren ? null : parseLooseJsonObject(inner);
let json = hasXmlChildren ? null : parseLooseJsonObject(inner);
if (!json && !hasXmlChildren) {
// Strict parse failed — try salvaging a complete JSON object from the start of the
// block even if trailing content after it is malformed (see salvageLeadingJsonObject).
const salvaged = salvageLeadingJsonObject(inner);
if (salvaged) json = parseLooseJsonObject(salvaged);
}
const jsonName = json ? (asString(json.name) ?? asString(json.type)) : null;
const childResolved = nameChild ? resolveRequestedToolName(nameChild, requested) : null;
@@ -479,7 +526,13 @@ export function parseDeepSeekToolCalls(
// A missing _nonce is tolerated for backward compatibility.
if (nonce) {
const parsed = parseLooseJsonObject(inner);
if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
if (
parsed &&
typeof parsed.name === "string" &&
parsed._nonce !== undefined &&
parsed._nonce !== nonce
)
continue;
}
toolCalls.push({

View File

@@ -0,0 +1,160 @@
// @ts-nocheck
// deepseek-web's non-stream/tool-call path (collectSSEContent) drains the upstream SSE
// body and returns whatever content it collected once the reader reports `done` — with
// no check that DeepSeek actually signalled completion via `response/status: "FINISHED"`.
// When the upstream cookie session drops mid-generation (expired session, anti-bot
// challenge, network interruption), the HTTP body simply closes early. Before this fix,
// that premature close was indistinguishable from a real completion: execute() returned
// HTTP 200 with `finish_reason: "stop"` and whatever partial stub text had arrived so far
// (observed in production: a lone "I'll check that..." with no continuation). The caller
// has no way to know the task was never actually finished, so it looks like the model just
// stopped mid-task.
//
// Fix: collectSSEContent now tracks whether the FINISHED status event was seen. If the
// stream ends without it, it throws instead of returning the stub — execute()'s existing
// try/catch turns that into a proper 502 the client (or a combo's retry/fallback logic)
// can react to.
import test from "node:test";
import assert from "node:assert/strict";
const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
const { DeepSeekWebExecutor } = dsMod;
const POW_CHALLENGE = {
algorithm: "DeepSeekHashV1",
challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
salt: "1122334455667788",
signature: "sig123",
difficulty: 1,
expire_at: 1778891543095,
expire_after: 300000,
target_path: "/api/v0/chat/completion",
};
// Same shape as a real completion, but the upstream body closes right after the partial
// text fragment — no `response/status: "FINISHED"` line ever arrives. This is what a
// dropped cookie session / anti-bot cutoff / network interruption looks like on the wire.
function sseWithPrematureClose(text) {
return [
"event: ready\n",
'data: {"request_message_id":1,"response_message_id":2}\n',
"\n",
`data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
"\n",
// (no response/status FINISHED event, no close event — body just ends here)
].join("");
}
function sseWithFinished(text) {
return [
"event: ready\n",
'data: {"request_message_id":1,"response_message_id":2}\n',
"\n",
`data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
"\n",
'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
"\n",
"event: close\n",
'data: {"click_behavior":"none"}\n',
].join("");
}
function installMock(sseBody) {
const original = globalThis.fetch;
dsMod.tokenCache?.clear();
dsMod.sessionCache?.clear();
globalThis.fetch = async (url, _opts = {}) => {
const u = String(url);
if (u.includes("/users/current"))
return new Response(
JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
if (u.includes("/chat_session/create"))
return new Response(
JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
if (u.includes("/chat_session/delete"))
return new Response(JSON.stringify({ code: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
if (u.includes("/create_pow_challenge"))
return new Response(
JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
if (u.includes("/chat/completion")) {
return new Response(new TextEncoder().encode(sseBody), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return new Response("not found", { status: 404 });
};
return {
restore: () => {
globalThis.fetch = original;
dsMod.tokenCache?.clear();
dsMod.sessionCache?.clear();
},
};
}
const TOOLS = [
{
type: "function",
function: {
name: "get_weather",
description: "Get weather",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
},
];
test("execute (tools[], non-stream) returns an error instead of a silent partial stub when the upstream session drops before FINISHED", async () => {
const mock = installMock(sseWithPrematureClose("I'll check the weather for you..."));
try {
const executor = new DeepSeekWebExecutor();
const result = await executor.execute({
model: "default",
body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
stream: false,
credentials: { apiKey: "tkn-premature-close" },
signal: AbortSignal.timeout(10000),
});
assert.equal(
result.response.status,
502,
"a session that closes before FINISHED must surface as an error, not HTTP 200"
);
const body = await result.response.text();
assert.ok(
/finished|premature|dropped|retry/i.test(body),
"error message should explain the session ended before completion"
);
} finally {
mock.restore();
}
});
test("execute (tools[], non-stream) still succeeds normally when FINISHED is received", async () => {
const mock = installMock(sseWithFinished("Just a normal answer, no tool needed."));
try {
const executor = new DeepSeekWebExecutor();
const result = await executor.execute({
model: "default",
body: { messages: [{ role: "user", content: "hi" }], tools: TOOLS },
stream: false,
credentials: { apiKey: "tkn-normal-finish" },
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok);
const json = JSON.parse(await result.response.text());
assert.equal(json.choices[0].finish_reason, "stop");
assert.ok(json.choices[0].message.content.includes("normal answer"));
} finally {
mock.restore();
}
});

View File

@@ -0,0 +1,176 @@
// @ts-nocheck
// When DeepSeek's web session returns a reply where a `<tool...>` tag is present but the
// block is genuinely unparseable (even after salvageLeadingJsonObject's recovery — e.g. the
// JSON itself is truncated), execute() now retries with a brand-new session (bounded to
// MAX_TOOL_PARSE_ATTEMPTS) before giving up. This is the scraped-web-session equivalent of
// retrying a flaky upstream call, since unlike a real API this provider is non-deterministic
// enough that asking again usually just works.
import test from "node:test";
import assert from "node:assert/strict";
const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
const { DeepSeekWebExecutor } = dsMod;
const POW_CHALLENGE = {
algorithm: "DeepSeekHashV1",
challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
salt: "1122334455667788",
signature: "sig123",
difficulty: 1,
expire_at: 1778891543095,
expire_after: 300000,
target_path: "/api/v0/chat/completion",
};
function sseWithContent(text) {
return [
"event: ready\n",
'data: {"request_message_id":1,"response_message_id":2}\n',
"\n",
`data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
"\n",
'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
"\n",
"event: close\n",
'data: {"click_behavior":"none"}\n',
].join("");
}
// installMock returns replies from `replies` in order, one per /chat/completion call — so
// the Nth upstream request (including retries) gets `replies[N-1]`.
function installMock(replies) {
const original = globalThis.fetch;
const calls = { completions: 0, sessionCreates: 0 };
dsMod.tokenCache?.clear();
dsMod.sessionCache?.clear();
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/users/current"))
return new Response(
JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
if (u.includes("/chat_session/create")) {
calls.sessionCreates += 1;
return new Response(
JSON.stringify({
code: 0,
data: { biz_data: { chat_session: { id: `s-${calls.sessionCreates}` } } },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (u.includes("/chat_session/delete"))
return new Response(JSON.stringify({ code: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
if (u.includes("/create_pow_challenge"))
return new Response(
JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
if (u.includes("/chat/completion")) {
const text = replies[Math.min(calls.completions, replies.length - 1)];
calls.completions += 1;
return new Response(new TextEncoder().encode(sseWithContent(text)), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return new Response("not found", { status: 404 });
};
return {
calls,
restore: () => {
globalThis.fetch = original;
dsMod.tokenCache?.clear();
dsMod.sessionCache?.clear();
},
};
}
const TOOLS = [
{
type: "function",
function: {
name: "get_weather",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
},
];
// Genuinely truncated — no balanced closing brace, so even salvageLeadingJsonObject cannot
// recover it. This is what a reply the retry must fix looks like.
const TRUNCATED = '<tool>{"name": "get_weather", "arguments": {"city": "Pa';
const GOOD_REPLY = '<tool>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool>';
test("retries with a fresh session when the first reply's tool block is unparseable, and succeeds on the second attempt", async () => {
const mock = installMock([TRUNCATED, GOOD_REPLY]);
try {
const executor = new DeepSeekWebExecutor();
const result = await executor.execute({
model: "default",
body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
stream: false,
credentials: { apiKey: "tkn-retry-success" },
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok);
const json = JSON.parse(await result.response.text());
const choice = json.choices[0];
assert.equal(choice.finish_reason, "tool_calls", "second attempt's valid reply must win");
assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
assert.equal(mock.calls.completions, 2, "exactly one retry (2 completions total)");
assert.equal(mock.calls.sessionCreates, 2, "retry uses a brand-new session, not the stale one");
} finally {
mock.restore();
}
});
test("gives up after MAX_TOOL_PARSE_ATTEMPTS and returns the raw (still-tagged) content, not an infinite retry", async () => {
const mock = installMock([TRUNCATED, TRUNCATED, TRUNCATED]);
try {
const executor = new DeepSeekWebExecutor();
const result = await executor.execute({
model: "default",
body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
stream: false,
credentials: { apiKey: "tkn-retry-exhausted" },
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok, "still HTTP 200 — a best-effort text answer, not a hard failure");
const json = JSON.parse(await result.response.text());
const choice = json.choices[0];
assert.equal(choice.finish_reason, "stop");
assert.ok(!choice.message.tool_calls, "no tool_calls on an unrecoverable reply");
assert.ok(
choice.message.content.includes("<tool>"),
"raw unparsed content is surfaced, not silently dropped"
);
assert.equal(mock.calls.completions, 2, "bounded to MAX_TOOL_PARSE_ATTEMPTS (2), never more");
} finally {
mock.restore();
}
});
test("does not retry at all when the first reply parses cleanly (no wasted latency)", async () => {
const mock = installMock([GOOD_REPLY, GOOD_REPLY, GOOD_REPLY]);
try {
const executor = new DeepSeekWebExecutor();
const result = await executor.execute({
model: "default",
body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
stream: false,
credentials: { apiKey: "tkn-no-retry-needed" },
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok);
const json = JSON.parse(await result.response.text());
assert.equal(json.choices[0].finish_reason, "tool_calls");
assert.equal(mock.calls.completions, 1, "a clean first reply must not trigger any retry");
assert.equal(mock.calls.sessionCreates, 1);
} finally {
mock.restore();
}
});

View File

@@ -0,0 +1,71 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right after
// an otherwise-complete `<tool>{json}` body, instead of a clean `</tool>` close. The strict
// `JSON.parse` inside `parseLooseJsonObject` rejects the whole block over that trailing
// garbage even though a perfectly valid object sits at the start. `salvageLeadingJsonObject`
// recovers it by scanning for the first balanced `{...}` (quote/escape aware) and parsing
// just that slice.
const TOOLS = [
{
type: "function",
function: {
name: "create_file",
parameters: {
type: "object",
properties: { filePath: { type: "string" }, content: { type: "string" } },
},
},
},
];
describe("deepseekWebTools — salvage leading JSON on malformed close", () => {
test("recovers a valid <tool>{json} block whose closing tag was replaced by garbled tokens", () => {
// Reproduces production content observed from the deepseek-web provider: valid JSON
// immediately followed by corrupted pseudo-tags instead of `</tool>`.
const text =
'Let me create that file.\n\n<tool>{"name": "create_file", "arguments": ' +
'{"filePath":"C:\\\\Users\\\\me\\\\script.mjs","content":"console.log(1)"}}' +
"<DSML parameter>\n</DSML invoke>\n</DSML calls>" +
"This response is AI-generated, for reference only.";
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
assert.ok(toolCalls && toolCalls.length === 1, "expected the malformed block to be recovered");
assert.equal(toolCalls![0].function.name, "create_file");
const args = JSON.parse(toolCalls![0].function.arguments);
assert.equal(args.filePath, "C:\\Users\\me\\script.mjs");
assert.equal(args.content, "console.log(1)");
});
test("recovers a valid block even with escaped quotes and nested braces before the garbage", () => {
const text =
'<tool>{"name": "create_file", "arguments": {"filePath":"a.txt",' +
'"content":"line one\\nline \\"two\\" {not json}"}}' +
"<DSML calls>trailing junk that is not valid JSON at all {{{";
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
assert.ok(toolCalls && toolCalls.length === 1);
const args = JSON.parse(toolCalls![0].function.arguments);
assert.equal(args.content, 'line one\nline "two" {not json}');
});
test("still returns null (no promotion) when the JSON itself is genuinely truncated", () => {
// No balanced closing brace anywhere — nothing to salvage, must not be promoted.
const text = '<tool>{"name": "create_file", "arguments": {"filePath":"a.txt able to nev';
const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS);
assert.equal(toolCalls, null, "a truly truncated object must not be salvaged into a call");
assert.equal(content, text, "unrecovered content is returned unchanged");
});
test("normal, well-formed <tool>{json}</tool> blocks are unaffected (no regression)", () => {
const text =
'<tool>{"name": "create_file", "arguments": {"filePath":"a.txt","content":"x"}}</tool>';
const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS);
assert.equal(toolCalls?.length, 1);
assert.equal(toolCalls![0].function.name, "create_file");
assert.ok(!content.includes("<tool>"), "well-formed block is still stripped from content");
});
});