diff --git a/src/adapters/cline-pass-deepseek-v4-tool-replay.ts b/src/adapters/cline-pass-deepseek-v4-tool-replay.ts new file mode 100644 index 0000000000..e2d0369974 --- /dev/null +++ b/src/adapters/cline-pass-deepseek-v4-tool-replay.ts @@ -0,0 +1,69 @@ +import type { ProviderAdapter } from "./base"; + +const CLINE_PASS_DEEPSEEK_V4_MODELS = new Set([ + "cline-pass/deepseek-v4-flash", + "cline-pass/deepseek-v4-pro", +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function isClinePassDeepSeekV4Model(modelId: string): boolean { + return CLINE_PASS_DEEPSEEK_V4_MODELS.has(modelId); +} + +/** + * DeepSeek V4 can copy historical pre-tool narration back into the next turn and + * eventually degenerate into text-only "I'll call the tool" loops. For the two + * affected ClinePass models, replay historical assistant tool turns as the + * structured call only. Normal assistant messages, tool results, and separate + * reasoning metadata remain untouched. + */ +export function stripClinePassDeepSeekV4ToolReplayNarration( + body: string, + modelId: string, +): string { + if (!isClinePassDeepSeekV4Model(modelId)) return body; + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return body; + } + if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body; + + let changed = false; + const messages = parsed.messages.map(message => { + if (!isRecord(message) || message.role !== "assistant") return message; + const toolCalls = message.tool_calls; + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return message; + if (message.content === "") return message; + + changed = true; + return { ...message, content: "" }; + }); + + return changed ? JSON.stringify({ ...parsed, messages }) : body; +} + +/** + * Apply the ClinePass DeepSeek V4 replay compatibility policy after the ordinary + * OpenAI-chat request has been serialized. The adapter's response parsing and all + * non-target request behavior stay identical. + */ +export function withClinePassDeepSeekV4ToolReplayCompatibility( + adapter: ProviderAdapter, +): ProviderAdapter { + return { + ...adapter, + async buildRequest(parsed, incoming) { + const request = await adapter.buildRequest(parsed, incoming); + if (!isClinePassDeepSeekV4Model(parsed.modelId)) return request; + + const body = stripClinePassDeepSeekV4ToolReplayNarration(request.body, parsed.modelId); + return body === request.body ? request : { ...request, body }; + }, + }; +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 2b88338b3e..896dd381bc 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -1,6 +1,7 @@ import { createAnthropicAdapter } from "./anthropic"; import { createAzureAdapter } from "./azure"; import type { ProviderAdapter } from "./base"; +import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; import { createGoogleAdapter } from "./google"; @@ -57,7 +58,8 @@ export const ADAPTER_REGISTRY = { "openai-chat": { wire: "openai-chat", mutation: "codex-owned", - create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOpenAIChatAdapter(provider), + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => + withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)), }, anthropic: { wire: "anthropic", diff --git a/tests/cline-pass-deepseek-v4-tool-replay.test.ts b/tests/cline-pass-deepseek-v4-tool-replay.test.ts new file mode 100644 index 0000000000..16ef1e2c78 --- /dev/null +++ b/tests/cline-pass-deepseek-v4-tool-replay.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { createRegisteredAdapter } from "../src/adapters/registry"; +import { + stripClinePassDeepSeekV4ToolReplayNarration, +} from "../src/adapters/cline-pass-deepseek-v4-tool-replay"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const TARGET_MODELS = [ + "cline-pass/deepseek-v4-flash", + "cline-pass/deepseek-v4-pro", +] as const; + +const provider = { + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authMode: "key", + apiKey: "test-key", +} satisfies OcxProviderConfig; + +function parsedWithHybridToolTurn(modelId: string): OcxParsedRequest { + return { + modelId, + stream: true, + options: {}, + context: { + tools: [{ + name: "exec", + description: "Execute a command", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + additionalProperties: false, + }, + }], + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I should inspect the repository first." }, + { type: "text", text: "Let me run that now." }, + { + type: "toolCall", + id: "call_exec_1", + name: "exec", + arguments: { command: "git status --short" }, + }, + ], + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "call_exec_1", + toolName: "exec", + content: "clean", + isError: false, + timestamp: 2, + }, + { + role: "assistant", + content: [{ type: "text", text: "The repository is clean." }], + timestamp: 3, + }, + { + role: "user", + content: "Continue.", + timestamp: 4, + }, + ], + }, + }; +} + +function incoming() { + return { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }; +} + +async function outboundMessages(modelId: string): Promise>> { + const adapter = createRegisteredAdapter(provider); + const request = await adapter.buildRequest(parsedWithHybridToolTurn(modelId), incoming()); + const body = JSON.parse(request.body) as { messages?: Array> }; + return body.messages ?? []; +} + +describe("ClinePass DeepSeek V4 tool-call history replay", () => { + test.each(TARGET_MODELS)("strips historical assistant narration for %s while preserving the tool call", async modelId => { + const messages = await outboundMessages(modelId); + const toolTurn = messages.find(message => Array.isArray(message.tool_calls)); + + expect(toolTurn).toBeDefined(); + expect(toolTurn?.content).toBe(""); + expect(toolTurn?.tool_calls).toEqual([{ + id: "call_exec_1", + type: "function", + function: { + name: "exec", + arguments: JSON.stringify({ command: "git status --short" }), + }, + }]); + + const toolResult = messages.find(message => message.role === "tool"); + expect(toolResult?.tool_call_id).toBe("call_exec_1"); + expect(toolResult?.content).toBe("clean"); + + const finalAssistant = messages.find(message => message.role === "assistant" && message.content === "The repository is clean."); + expect(finalAssistant).toBeDefined(); + }); + + test("leaves non-target OpenAI-chat requests byte-identical", async () => { + const parsed = parsedWithHybridToolTurn("cline-pass/not-deepseek-v4"); + const plainRequest = await createOpenAIChatAdapter(provider).buildRequest(parsed, incoming()); + const wrappedRequest = await createRegisteredAdapter(provider).buildRequest(parsed, incoming()); + + expect(wrappedRequest.body).toBe(plainRequest.body); + }); + + test("keeps reasoning metadata when stripping a target tool turn", () => { + const input = JSON.stringify({ + messages: [{ + role: "assistant", + content: "Let me call the tool.", + reasoning_content: "private reasoning", + tool_calls: [{ + id: "call_1", + type: "function", + function: { name: "exec", arguments: "{}" }, + }], + }], + }); + + const output = stripClinePassDeepSeekV4ToolReplayNarration( + input, + "cline-pass/deepseek-v4-flash", + ); + const body = JSON.parse(output) as { messages: Array> }; + + expect(body.messages[0]?.content).toBe(""); + expect(body.messages[0]?.reasoning_content).toBe("private reasoning"); + expect(body.messages[0]?.tool_calls).toEqual([{ + id: "call_1", + type: "function", + function: { name: "exec", arguments: "{}" }, + }]); + }); +});