From 41877978e02e79b2e17cf90f919d9760518d93c2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 19 Aug 2026 11:08:07 -0300 Subject: [PATCH] fix: auto-replay bounded trajectory in DeepSeek Web prompt builder for non-tool-calling agentic clients (#10527) (#10741) Co-authored-by: Markus Hartung --- .../10527-deepseek-web-context-amnesia.md | 1 + open-sse/executors/deepseek-web.ts | 41 ++++++++++--- .../deepseek-web-issue-10527-repro.test.ts | 60 +++++++++++++++++++ .../deepseek-web-rolling-window-2942.test.ts | 32 +++++++--- 4 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 changelog.d/fixes/10527-deepseek-web-context-amnesia.md create mode 100644 tests/unit/deepseek-web-issue-10527-repro.test.ts diff --git a/changelog.d/fixes/10527-deepseek-web-context-amnesia.md b/changelog.d/fixes/10527-deepseek-web-context-amnesia.md new file mode 100644 index 0000000000..4eadc0040a --- /dev/null +++ b/changelog.d/fixes/10527-deepseek-web-context-amnesia.md @@ -0,0 +1 @@ +- fix(sse): auto-replay a bounded multi-turn trajectory in the DeepSeek Web prompt builder for clients that never send `tools[]`, so agentic clients like Cline stop losing the original task after a couple of turns (#10527) diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 4f3314ca8d..2b0fccb489 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -498,17 +498,31 @@ function extractMessageText(content: unknown): string { return String(content || ""); } +// #10527 — with no explicit `historyWindow`, genuinely multi-turn conversations (any +// assistant turn present, or more than one user turn) now auto-replay a bounded +// trajectory instead of only the last user message, so agentic clients that never send +// OpenAI-native `tools[]` (e.g. Cline, which embeds its own XML tool convention) don't +// silently lose the original task after a couple of tool-result turns. This cap keeps +// the auto-replay bounded for very long agent sessions; set `historyWindow` explicitly +// on the connection to raise or lower it. +const DEFAULT_AUTO_HISTORY_WINDOW = 20; + /** * Build the single prompt string the DeepSeek web API accepts. * * The web endpoint (`/api/v0/chat/completion`) takes only a `prompt` string, not a - * `messages` array. With `historyWindow <= 0` (default) we keep the legacy behavior — - * system prompt(s) + the last user message only — which is fine for plain chat. + * `messages` array. For a genuinely single-turn request (one user message, no prior + * assistant turns) we keep the minimal behavior — system prompt(s) + the last user + * message only — which is fine for plain chat and avoids inflating token usage. * - * With `historyWindow > 0` we stitch the last N non-system messages into a role-tagged - * transcript so agentic multi-turn clients keep context across turns (rolling-window - * memory, #2942). The system prompt(s) still lead the prompt and the newest user turn - * is the last line of the transcript. + * For a multi-turn conversation, `historyWindow > 0` stitches the last N non-system + * messages into a role-tagged transcript so agentic multi-turn clients keep context + * across turns (rolling-window memory, #2942). With `historyWindow` unset/`<= 0` we now + * auto-apply a bounded window (`DEFAULT_AUTO_HISTORY_WINDOW`) instead of dropping every + * earlier turn (#10527) — the previous default silently discarded the original task + * after a couple of turns for clients (Cline) that never send `tools[]`. The system + * prompt(s) still lead the prompt and the newest user turn is the last line of the + * transcript. */ export function messagesToPrompt( messages: Array<{ role: string; content: string; tool_call_id?: string; name?: string }>, @@ -551,9 +565,18 @@ export function messagesToPrompt( parts.push(systemParts.join("\n\n")); } - if (historyWindow > 0 && conversation.length > 1) { - // Rolling-window transcript of the most recent turns (#2942). - const recent = conversation.slice(-historyWindow); + const effectiveWindow = + historyWindow > 0 + ? historyWindow + : conversation.length > 1 + ? DEFAULT_AUTO_HISTORY_WINDOW + : 0; + + if (effectiveWindow > 0 && conversation.length > 1) { + // Rolling-window transcript of the most recent turns (#2942, auto-applied per + // #10527 when no explicit historyWindow is configured and the conversation is + // genuinely multi-turn). + const recent = conversation.slice(-effectiveWindow); const transcript = recent .map((turn) => turn.role === "assistant" diff --git a/tests/unit/deepseek-web-issue-10527-repro.test.ts b/tests/unit/deepseek-web-issue-10527-repro.test.ts new file mode 100644 index 0000000000..589ff21b7e --- /dev/null +++ b/tests/unit/deepseek-web-issue-10527-repro.test.ts @@ -0,0 +1,60 @@ +// Regression test for issue #10527 — "Deepseek web just never does anything" (Cline). +// +// DeepSeek Web has no native messages[]/tool-calling API, so OmniRoute flattens the chat +// history into a single `prompt` string via messagesToPrompt(). Cline (and most +// XML-tool-convention agentic clients) never sends OpenAI-native `tools[]`, so it always +// takes this non-tool path. With the old default (historyWindow<=0 -> system + last user +// message only), the original task instruction was silently dropped after a couple of +// tool-result turns, causing the model to lose the task and loop asking "what do you want +// me to do?". The fix auto-applies a bounded rolling window for genuinely multi-turn +// conversations even when historyWindow is unset. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { messagesToPrompt } = await import("../../open-sse/executors/deepseek-web.ts"); + +test("#10527: default (no tools[], no historyWindow) keeps the original task after a few Cline-style turns", () => { + const clineStyleConversation = [ + { role: "system", content: "You are Cline, an autonomous coding agent. Use the available tools." }, + { role: "user", content: "Add input validation to the /api/v1/signup route and write a test for it." }, + { + role: "assistant", + content: + "I'll look at the signup route first.\nsrc/app/api/v1/signup/route.ts", + }, + { + role: "user", + content: + "[read_file for 'src/app/api/v1/signup/route.ts'] Result:\nexport async function POST(req) { /* ...200 lines... */ }", + }, + { + role: "assistant", + content: + "Now let me check the existing validation schema.\nsrc/lib/validation/signup.ts", + }, + { + role: "user", + content: + "[read_file for 'src/lib/validation/signup.ts'] Result:\nexport const signupSchema = z.object({ /* ... */ });", + }, + ]; + + const upstreamPrompt = messagesToPrompt(clineStyleConversation, 0 /* default historyWindow */); + + assert.ok( + upstreamPrompt.includes("Add input validation to the /api/v1/signup route"), + "#10527: the original task instruction must survive in the upstream prompt " + + "even after a couple of Cline-style tool-result turns.\n\n" + + `Actual upstream prompt sent to chat.deepseek.com:\n${upstreamPrompt}` + ); +}); + +test("#10527: genuinely single-turn request keeps the minimal system + last-user-only prompt", () => { + const singleTurn = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "What is 2+2?" }, + ]; + + const upstreamPrompt = messagesToPrompt(singleTurn, 0); + assert.equal(upstreamPrompt, "You are helpful.\n\nWhat is 2+2?"); +}); diff --git a/tests/unit/deepseek-web-rolling-window-2942.test.ts b/tests/unit/deepseek-web-rolling-window-2942.test.ts index 00320158d3..1ae1ea592b 100644 --- a/tests/unit/deepseek-web-rolling-window-2942.test.ts +++ b/tests/unit/deepseek-web-rolling-window-2942.test.ts @@ -1,7 +1,14 @@ // #2942 — rolling-window prompt memory for deepseek-web. The web API takes only a single -// `prompt` string, so multi-turn context must be stitched into that prompt. With the -// window disabled (default) the legacy behavior (system + last user only) is preserved; -// with a window > 0, the last N turns are stitched into a role-tagged transcript. +// `prompt` string, so multi-turn context must be stitched into that prompt. With an +// explicit `historyWindow > 0`, the last N turns are stitched into a role-tagged +// transcript. +// +// #10527 — with the window unset/<=0 (default), a genuinely multi-turn conversation +// (any assistant turn present, or more than one user turn) now auto-replays a bounded +// trajectory instead of the old "system + last user only" behavior, which silently +// dropped the original task for agentic clients (Cline) that never send OpenAI-native +// `tools[]`. Only a genuinely single-turn request (one user message, no assistant turns) +// keeps the minimal "system + last user only" prompt. import test from "node:test"; import assert from "node:assert/strict"; @@ -14,18 +21,27 @@ const CONVO = [ { role: "user", content: "second question" }, ]; -test("window 0 (default) keeps legacy behavior: system + last user only", () => { +test("window 0 (default) on a multi-turn conversation auto-replays the bounded trajectory (#10527)", () => { const prompt = messagesToPrompt(CONVO, 0); assert.ok(prompt.includes("You are helpful."), "system prompt present"); assert.ok(prompt.includes("second question"), "last user message present"); - assert.ok(!prompt.includes("first question"), "earlier user turn must be dropped"); - assert.ok(!prompt.includes("first answer"), "assistant turn must be dropped"); + assert.ok(prompt.includes("first question"), "earlier user turn must survive (#10527)"); + assert.ok(prompt.includes("first answer"), "assistant turn must survive (#10527)"); }); -test("default call (no window arg) behaves like window 0", () => { +test("default call (no window arg) on a multi-turn conversation behaves like window 0 (#10527)", () => { const prompt = messagesToPrompt(CONVO); assert.ok(prompt.includes("second question")); - assert.ok(!prompt.includes("first answer")); + assert.ok(prompt.includes("first answer"), "assistant turn must survive (#10527)"); +}); + +test("window 0 (default) on a genuinely single-turn request keeps the minimal system + last-user-only prompt", () => { + const singleTurn = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "second question" }, + ]; + const prompt = messagesToPrompt(singleTurn, 0); + assert.equal(prompt, "You are helpful.\n\nsecond question"); }); test("window > 0 stitches recent turns into a role-tagged transcript", () => {