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", () => {