From 86ae3f0eaf8fe529658fc44ff9079de1eecc1c3e Mon Sep 17 00:00:00 2001 From: LongLongBigInt Date: Sat, 10 Oct 2026 14:05:27 +0800 Subject: [PATCH 1/2] fix(core): replay assistant reasoning on the generate fallback The generate transport dropped assistant thinking blocks when building request history, so the model could not see its own earlier reasoning and re-decided hidden state on follow-up turns. The official Command Code CLI replays thinking as `{type:"reasoning",text}` in `toWireMessages`, and pi's native providers keep prior reasoning in history too. Add the thinking branch to `messagesToCC()`, cover it with unit and stream-request tests, and keep a live-capture fixture pair (session history + generate request body) for cumulative replay. List the behavior change in the changelog. --- CHANGELOG.md | 4 ++ src/converters.ts | 5 ++ tests/fixtures/issue-146-generate-turn3.json | 60 ++++++++++++++++++++ tests/fixtures/issue-146-session.json | 59 +++++++++++++++++++ tests/test-pure-functions.ts | 37 +++++++++--- tests/test-stream.ts | 36 ++++++++++++ 6 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/issue-146-generate-turn3.json create mode 100644 tests/fixtures/issue-146-session.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5e92c..805321f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Replay prior assistant reasoning on the generate transport so the model can see its own earlier thinking on follow-up turns, matching the official Command Code CLI (`toWireMessages` in `command-code@1.79.2`) and pi's native providers. Prior reasoning was dropped in 0.5.0, which made multi-turn tasks whose state lives only in thinking — plans, games, consistency checks — silently re-decide on the fallback transport. + ## 0.7.6 - 2026-10-06 - Correct the display input price for `stepfun/Step-3.5-Flash` to $0.09 per million tokens and add the `Qwen/Qwen3.6-Plus` long-context tier above 256K tokens, verified against the official pricing page on 2026-10-06. Mark the unchanged, expired `qwen-3.7-max-2x-usage` badge as reviewed in the pricing checker while keeping it visible in reports; changed deal terms and price drift still require review. diff --git a/src/converters.ts b/src/converters.ts index 4d028a9..6933b5e 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -268,6 +268,11 @@ export function messagesToCC( for (const content of recordArray(message.content)) { if (content.type === "text") { parts.push({ type: "text", text: stringValue(content.text) ?? "" }) + } else if (content.type === "thinking") { + // The official Command Code CLI replays prior assistant reasoning as + // `reasoning` parts (toWireMessages), and dropping it leaves the model + // unable to see its own earlier thinking on follow-up turns. + parts.push({ type: "reasoning", text: stringValue(content.thinking) ?? "" }) } else if (content.type === "toolCall") { const toolCallId = stringValue(content.id) ?? "" const toolName = stringValue(content.name) ?? "" diff --git a/tests/fixtures/issue-146-generate-turn3.json b/tests/fixtures/issue-146-generate-turn3.json new file mode 100644 index 0000000..a84a16a --- /dev/null +++ b/tests/fixtures/issue-146-generate-turn3.json @@ -0,0 +1,60 @@ +{ + "note": "params.messages captured from the live turn-3 POST /alpha/generate request for the history in issue-146-session.json. Both prior assistant turns carry a reasoning part.", + "model": "deepseek/deepseek-v4.1-flash", + "reasoning_effort": "max", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Let's play a guessing game. Think of a number between 1 and 100 and I'll guess. After each guess, tell me if it's higher or lower." + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "text": "The user wants to play a guessing game. They want me to think of a number between 1 and 100, and they'll guess. After each guess I tell them higher or lower.\n\nThis is a roleplay task. I need to pick a number and keep it consistent. Let me pick something like 42... no, too obvious. Let me pick 73. Actually, any number works. I'll pick 37 or 73. Let me go with 73.\n\nI should just respond playfully, tell them I've thought of a number, and invite their first guess. I need to remember the number throughout the conversation. Let me pick 73.\n\nKeep response concise." + }, + { + "type": "text", + "text": "I've got one. It's between 1 and 100 — go ahead and make your first guess!" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "66" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "text": "The number is 73. 66 is lower than 73, so I say higher." + }, + { + "type": "text", + "text": "Higher!" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "50" + } + ] + } + ] +} diff --git a/tests/fixtures/issue-146-session.json b/tests/fixtures/issue-146-session.json new file mode 100644 index 0000000..6c73d93 --- /dev/null +++ b/tests/fixtures/issue-146-session.json @@ -0,0 +1,59 @@ +{ + "note": "Sanitized history of a live 3-turn guessing-game session on the generate fallback (deepseek/deepseek-v4.1-flash, thinking max, tools=[read]). Each assistant turn carries hidden thinking plus visible text and no tool calls; the final assistant reply is omitted because it is not part of the turn-3 request.", + "model": "deepseek/deepseek-v4.1-flash", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Let's play a guessing game. Think of a number between 1 and 100 and I'll guess. After each guess, tell me if it's higher or lower." + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "The user wants to play a guessing game. They want me to think of a number between 1 and 100, and they'll guess. After each guess I tell them higher or lower.\n\nThis is a roleplay task. I need to pick a number and keep it consistent. Let me pick something like 42... no, too obvious. Let me pick 73. Actually, any number works. I'll pick 37 or 73. Let me go with 73.\n\nI should just respond playfully, tell them I've thought of a number, and invite their first guess. I need to remember the number throughout the conversation. Let me pick 73.\n\nKeep response concise." + }, + { + "type": "text", + "text": "I've got one. It's between 1 and 100 — go ahead and make your first guess!" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "66" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "The number is 73. 66 is lower than 73, so I say higher." + }, + { + "type": "text", + "text": "Higher!" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "50" + } + ] + } + ] +} diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 22e22a4..c6858df 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -4,7 +4,7 @@ */ import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { describe, it } from "node:test" @@ -713,9 +713,11 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["0", "role"]), "user") assert.equal(objectAt(result, ["1", "role"]), "assistant") - assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call") - assert.equal(objectAt(result, ["1", "content", "2"]), undefined) + assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning") + assert.equal(objectAt(result, ["1", "content", "0", "text"]), "I will read") + assert.equal(objectAt(result, ["1", "content", "1", "type"]), "text") + assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call") + assert.equal(objectAt(result, ["1", "content", "3"]), undefined) assert.equal(objectAt(result, ["2", "role"]), "tool") assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") }) @@ -942,7 +944,7 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "plain result") }) - it("drops previous assistant reasoning while preserving text and tool calls", () => { + it("replays previous assistant reasoning while preserving text and tool calls", () => { const result = messagesToCC([ { role: "user", content: "first question" }, { @@ -957,12 +959,18 @@ describe("messagesToCC()", () => { assert.deepEqual(result, [ { role: "user", content: "first question" }, - { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "private reasoning from turn one" }, + { type: "text", text: "first answer" }, + ], + }, { role: "user", content: "follow-up question" }, ]) }) - it("omits assistant turns that contain only previous reasoning", () => { + it("keeps assistant turns that contain only previous reasoning", () => { const result = messagesToCC([ { role: "user", content: "first question" }, { @@ -974,10 +982,25 @@ describe("messagesToCC()", () => { assert.deepEqual(result, [ { role: "user", content: "first question" }, + { + role: "assistant", + content: [{ type: "reasoning", text: "private reasoning" }], + }, { role: "user", content: "follow-up question" }, ]) }) + it("replays cumulative reasoning for text-only assistant turns in a tool-enabled session (#146 fixtures)", () => { + const session = JSON.parse( + readFileSync(new URL("./fixtures/issue-146-session.json", import.meta.url), "utf8"), + ) as { messages: Parameters[0] } + const expected = JSON.parse( + readFileSync(new URL("./fixtures/issue-146-generate-turn3.json", import.meta.url), "utf8"), + ) as { messages: ReturnType } + + assert.deepEqual(messagesToCC(session.messages), expected.messages) + }) + it("synthesizes missing results for orphaned tool calls", () => { const result = messagesToCC([ { role: "user", content: "edit a file" }, diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 9a3486e..2731ca2 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -777,6 +777,42 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers["x-session-id"], undefined) }) + it("replays previous assistant reasoning in the generate request history", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode( + makeModel(), + makeContext({ + messages: [ + { role: "user", content: "think of a number" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "my number is 57" }, + { type: "text", text: "ready" }, + ], + }, + { role: "user", content: "is it bigger than 50?" }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + assert.deepEqual(objectAt(server.lastRequestBody(), ["params", "messages", "1"]), { + role: "assistant", + content: [ + { type: "reasoning", text: "my number is 57" }, + { type: "text", text: "ready" }, + ], + }) + }) + it("normalizes nullable tool parameters only for Gemini on the generate wire (#99)", async () => { server.mockResponse({ type: "success", From d24cadfb567faa83aa214309ea9d190005458d04 Mon Sep 17 00:00:00 2001 From: LongLongBigInt <158790984+LongLongBigInt@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:15:05 +0800 Subject: [PATCH 2/2] docs(changelog): scope generate reasoning parity to the Command Code CLI --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 805321f..3e06a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Replay prior assistant reasoning on the generate transport so the model can see its own earlier thinking on follow-up turns, matching the official Command Code CLI (`toWireMessages` in `command-code@1.79.2`) and pi's native providers. Prior reasoning was dropped in 0.5.0, which made multi-turn tasks whose state lives only in thinking — plans, games, consistency checks — silently re-decide on the fallback transport. +- Replay prior assistant reasoning on the generate transport so the model can see its own earlier thinking on follow-up turns, matching the Command Code CLI's `/alpha/generate` wire (`toWireMessages` in `command-code@1.79.2`), which replays every thinking block as a `reasoning` part with no model check and no signature. Prior reasoning was dropped in 0.5.0, which made multi-turn tasks whose state lives only in thinking — plans, games, consistency checks — silently re-decide on the fallback transport. ## 0.7.6 - 2026-10-06