diff --git a/CHANGELOG.md b/CHANGELOG.md index 87264cd..78e18a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Refresh the static capability snapshot to `command-code@1.79.2`: add `claude-haiku-5-5`, `mistral/mistral-large-4` (image input, 262K output limit), and the free `stealth/glyph-cluster:free`; retire `stealth/pixel-canary` and `stealth/space-bunny-alpha`. - Add reviewed display pricing for the refreshed catalog — `claude-haiku-5-5` with its 100K long-context tier, `mistral/mistral-large-4`, and the free `stealth/glyph-cluster:free` — drop the retired `stealth/space-bunny-alpha`, and correct the `claude-sonnet-5-5` cache-read rate to $0.10 per million tokens, verified against the official pricing page on 2026-10-09. The snapshot now covers all 87 advertised models. - Fix `400 input[N].call_id` failures on the `/alpha/generate` fallback by shortening compound Responses tool-call ids (`|`, 73 characters in the reported session) to the 64-character wire limit. The mapping is deterministic per request and shared by tool calls and their results, so pairing is preserved and distinct ids never collide; short ids pass through unchanged and the Provider API path is untouched ([#141](https://github.com/patlux/pi-commandcode-provider/issues/141)). +- 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 diff --git a/src/converters.ts b/src/converters.ts index c6fa0ad..f4d33d5 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -347,6 +347,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 rawToolCallId = 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 851dc59..5971dd8 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 b320206..402e384 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -781,6 +781,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",