diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index 01ee533af3..3e288c0ba7 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -485,6 +485,117 @@ test("Command Code executor falls back to /alpha/generate on 403 (Go plan) for n assert.equal(usage.total_tokens, 5); }); +// Simulates a Go-plan key: /provider/v1/chat/completions answers 403 and the executor +// falls back to /alpha/generate, whose CLI SSE stream is built from `cliLines`. +function goPlanFallbackFetch(cliLines: unknown[]) { + const calls: string[] = []; + globalThis.fetch = async (url) => { + const urlStr = String(url); + calls.push(urlStr); + + if (urlStr.includes("/provider/v1/chat/completions")) { + return new Response( + JSON.stringify({ error: { message: "upgrade_required", code: "upgrade_required" } }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + if (urlStr.includes("/alpha/generate")) { + const cliSse = cliLines.map((line) => `data: ${JSON.stringify(line)}\n\n`).join(""); + return new Response(cliSse, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + return new Response("Not found", { status: 404 }); + }; + return calls; +} + +test("Command Code /alpha/generate fallback: reasoning-only output falls back to reasoning as content (non-stream) (#10986)", async () => { + const calls = goPlanFallbackFetch([ + { type: "reasoning-delta", text: "The user wants 79874+93658. " }, + { type: "reasoning-delta", text: "That equals 173532." }, + { + type: "finish", + finishReason: "stop", + totalUsage: { + inputTokens: 20, + outputTokens: 64, + outputTokenDetails: { reasoningTokens: 61 }, + }, + }, + ]); + + const { response, url } = await ( + await getExecutor("command-code") + ).execute({ + model: "deepseek/deepseek-v4-flash", + stream: false, + credentials: { apiKey: "cc_go_plan_key" }, + body: { + messages: [ + { role: "user", content: "Calculate 79874+93658, and reply with the result only." }, + ], + }, + }); + + assert.equal(calls.length, 2, "probed /provider/v1 first, then fell back to /alpha/generate"); + assert.ok(url.includes("/alpha/generate")); + const json = (await response.json()) as { + choices: Array<{ + message: { content: string; reasoning_content?: string }; + finish_reason: string; + }>; + usage: { completion_tokens_details: { reasoning_tokens: number } }; + }; + const message = json.choices[0].message; + // Regression #10986: when the model emits only reasoning-delta events (never a + // text-delta), content must fall back to the reasoning text instead of "" (which + // OpenAI-compatible clients treat as null/no answer). + assert.equal(message.content, "The user wants 79874+93658. That equals 173532."); + // reasoning_content must STAY populated for reasoning-aware clients. + assert.equal(message.reasoning_content, "The user wants 79874+93658. That equals 173532."); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.equal(json.usage.completion_tokens_details.reasoning_tokens, 61); +}); + +test("Command Code /alpha/generate fallback: reasoning-only output emits a content delta chunk when streaming (#10986)", async () => { + const calls = goPlanFallbackFetch([ + { type: "reasoning-delta", text: "The result is 173532." }, + { type: "finish", finishReason: "stop" }, + ]); + + const { response, url } = await ( + await getExecutor("command-code") + ).execute({ + model: "deepseek/deepseek-v4-flash", + stream: true, + credentials: { apiKey: "cc_go_plan_key" }, + body: { messages: [{ role: "user", content: "Calcular 79874+93658" }] }, + }); + + assert.equal(calls.length, 2, "probed /provider/v1 first, then fell back to /alpha/generate"); + assert.ok(url.includes("/alpha/generate")); + const sse = await response.text(); + assert.match(sse, /data: \[DONE\]/); + const chunks = parseSsePayloads(sse); + assert.equal(chunks[0].choices[0].delta.role, "assistant"); + // Regression #10986: the reasoning-only stream must emit a content delta when it + // otherwise ends with no content. reasoning_content stays present too. + const contentChunks = chunks.filter((c) => c.choices[0]?.delta?.content !== undefined); + assert.equal(contentChunks.length, 1, "exactly one synthesized content delta"); + assert.equal(contentChunks[0].choices[0].delta.content, "The result is 173532."); + const reasoningDelta = chunks.find((c) => c.choices[0]?.delta?.reasoning_content !== undefined); + assert.equal(reasoningDelta.choices[0].delta.reasoning_content, "The result is 173532."); + // The synthesized content lands after the reasoning delta and before the finish chunk. + const finishIndex = chunks.findIndex((c) => c.choices[0]?.finish_reason === "stop"); + assert.ok(finishIndex > chunks.indexOf(contentChunks[0])); + assert.ok(chunks.indexOf(contentChunks[0]) > chunks.indexOf(reasoningDelta)); + assert.equal(chunks[finishIndex].choices[0].finish_reason, "stop"); +}); + test("Command Code executor surfaces fallback error when both /provider/v1 and /alpha/generate fail", async () => { globalThis.fetch = async (url) => { const urlStr = String(url);