From b9a723355bffadf2ef10d5c2eacbdd0f0d36eb6a Mon Sep 17 00:00:00 2001 From: pierreraby Date: Thu, 8 Oct 2026 19:03:52 +0200 Subject: [PATCH 1/2] fix(core): shorten compound tool-call ids on the generate fallback Hosts store Responses tool-call ids compounded as | (73 characters for the observed call_<32hex>|fc_<32hex> shape). The /alpha/generate backend rejects call_id values longer than 64 characters with 400 input[N].call_id, so the fallback maps every referenced id to a deterministic per-request wire form shared by calls and results: pairing is preserved, distinct ids never collide, and short ids pass through unchanged. The Provider API path is untouched. Closes #141 --- CHANGELOG.md | 4 ++ src/converters.ts | 80 ++++++++++++++++++++++++-- tests/test-pure-functions.ts | 108 +++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5e92c..c3216ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- 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)). + ## 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..47ae9d6 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" @@ -219,6 +220,74 @@ interface ToolCallState { resultIds: ReadonlySet } +/** + * Bound for tool-call ids on the `/alpha/generate` wire (#141). + * + * Hosts store Responses tool-call ids compounded as `|` + * (73 characters for the observed `call_<32hex>|fc_<32hex>` shape). The + * generate backend rejects `call_id` values longer than 64 characters with + * `400 input[N].call_id`, so compound ids must be shortened before sending. + * The mapping is deterministic per request and shared by calls and results, + * so pairing is preserved and distinct ids never collide. + */ +const GENERATE_TOOL_CALL_ID_LIMIT = 64 + +function generateToolCallIdHash(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 8) +} + +function shortenGenerateToolCallId(value: string): string { + if (value.length <= GENERATE_TOOL_CALL_ID_LIMIT) return value + const suffix = generateToolCallIdHash(value) + const head = value.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+$/, "") + const keep = GENERATE_TOOL_CALL_ID_LIMIT - suffix.length - 1 + const truncated = head.slice(0, Math.max(0, keep)).replace(/_+$/, "") + // Fall back to the raw hash when sanitizing leaves nothing usable. + return truncated ? `${truncated}_${suffix}` : suffix +} + +/** + * Map every tool-call id referenced in this request to its generate-wire + * form. Short ids pass through unchanged; overlong ids are shortened + * deterministically, and on collision the later id keeps the next free + * `_` suffix so every distinct id still maps to a distinct value. + */ +function generateToolCallIdMap(messages?: readonly MessageLike[]): Map { + const referenced = new Set() + for (const message of messages ?? []) { + if (message.role === "assistant") { + for (const content of recordArray(message.content)) { + if (content.type === "toolCall") { + const id = stringValue(content.id) + if (id) referenced.add(id) + } + } + } else if (message.role === "toolResult" && message.toolCallId) { + referenced.add(message.toolCallId) + } + } + + const used = new Set() + const mapped = new Map() + for (const id of referenced) { + let candidate = shortenGenerateToolCallId(id) + if (used.has(candidate)) { + const base = candidate.replace(/_+$/, "") + let attempt = 0 + do { + const suffix = generateToolCallIdHash(`${id}#${attempt}`) + const keep = GENERATE_TOOL_CALL_ID_LIMIT - suffix.length - 1 + const head = base.slice(0, Math.max(0, keep)).replace(/_+$/, "") + candidate = head ? `${head}_${suffix}` : suffix + attempt += 1 + } while (used.has(candidate)) + } + mapped.set(id, candidate) + used.add(candidate) + } + return mapped +} + function toolCallState(messages?: readonly MessageLike[]): ToolCallState { const callIds = new Set() const resultIds = new Set() @@ -248,6 +317,8 @@ export function messagesToCC( const out: unknown[] = [] const { callIds, resultIds } = toolCallState(messages) + const wireIds = generateToolCallIdMap(messages) + const wireId = (id: string): string => wireIds.get(id) ?? id const rawMessages = messages ?? [] for (let i = 0; i < rawMessages.length; i++) { @@ -269,16 +340,17 @@ export function messagesToCC( if (content.type === "text") { parts.push({ type: "text", text: stringValue(content.text) ?? "" }) } else if (content.type === "toolCall") { - const toolCallId = stringValue(content.id) ?? "" + const rawToolCallId = stringValue(content.id) ?? "" const toolName = stringValue(content.name) ?? "" - if (!toolCallId) continue + if (!rawToolCallId) continue + const toolCallId = wireId(rawToolCallId) parts.push({ type: "tool-call", toolCallId, toolName, input: recordOrEmpty(content.arguments), }) - if (!resultIds.has(toolCallId)) { + if (!resultIds.has(rawToolCallId)) { missingResults.push({ type: "tool-result", toolCallId, @@ -311,7 +383,7 @@ export function messagesToCC( content: [ { type: "tool-result", - toolCallId: toolMsg.toolCallId, + toolCallId: wireId(toolMsg.toolCallId), toolName: toolMsg.toolName, output: toolMsg.isError ? { type: "error-text", value: outputText } diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 22e22a4..5b8e063 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -1005,6 +1005,114 @@ describe("messagesToCC()", () => { ) }) + it("shortens compound tool-call ids to the generate wire limit without breaking pairs", () => { + const compound = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + assert.equal(compound.length, 73) + + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: compound, name: "bash", arguments: { command: "ls" } }], + }, + { + role: "toolResult", + toolCallId: compound, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + }, + ]) + + const wireCall = objectAt(result, ["0", "content", "0", "toolCallId"]) + assert.equal(typeof wireCall, "string") + assert.ok((wireCall as string).length <= 64) + assert.notEqual(wireCall, compound) + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), wireCall) + }) + + it("leaves short tool-call ids unchanged", () => { + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "bash", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "bash", + content: [{ type: "text", text: "ok" }], + }, + ]) + + assert.equal(objectAt(result, ["0", "content", "0", "toolCallId"]), "c1") + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), "c1") + }) + + it("keeps distinct overlong tool-call ids distinct on the generate wire", () => { + const first = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const second = `call_${"a".repeat(31)}c|fc_${"b".repeat(32)}` + const result = messagesToCC([ + { + role: "assistant", + content: [ + { type: "toolCall", id: first, name: "bash", arguments: {} }, + { type: "toolCall", id: second, name: "bash", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: first, + toolName: "bash", + content: [{ type: "text", text: "one" }], + }, + { + role: "toolResult", + toolCallId: second, + toolName: "bash", + content: [{ type: "text", text: "two" }], + }, + ]) + + const wireFirst = objectAt(result, ["0", "content", "0", "toolCallId"]) + const wireSecond = objectAt(result, ["0", "content", "1", "toolCallId"]) + assert.ok((wireFirst as string).length <= 64) + assert.ok((wireSecond as string).length <= 64) + assert.notEqual(wireFirst, wireSecond) + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), wireFirst) + assert.equal(objectAt(result, ["2", "content", "0", "toolCallId"]), wireSecond) + }) + + it("maps synthesized missing results with the shortened tool-call id", () => { + const compound = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: compound, name: "edit", arguments: { path: "x" } }], + }, + ]) + + const wireCall = objectAt(result, ["0", "content", "0", "toolCallId"]) + assert.ok((wireCall as string).length <= 64) + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), wireCall) + }) + + it("shortens the same tool-call id deterministically", () => { + const compound = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const messages: MessageLike[] = [ + { + role: "assistant", + content: [{ type: "toolCall", id: compound, name: "bash", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: compound, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + }, + ] + + assert.deepEqual(messagesToCC(messages), messagesToCC(messages)) + }) + it("handles empty conversations", () => { assert.deepEqual(messagesToCC([]), []) }) From 12085c3eb3a1ebc9af74bf2d389003d143dc6c63 Mon Sep 17 00:00:00 2001 From: pierreraby Date: Fri, 9 Oct 2026 03:28:25 +0200 Subject: [PATCH 2/2] fix(core): reserve short tool-call ids before shortening Reserve every pre-existing generate tool-call id within the 64-character limit before allocating shortened wire ids. Long-id collisions keep using the hash-suffix fallback, so short ids remain unchanged regardless of message order. Add both-order regressions covering short-id preservation, distinct bounded wire ids, and call/result pairing. Addresses the edge case reported by @karaaslanz on PR #144. --- src/converters.ts | 14 +++++++++++--- tests/test-pure-functions.ts | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/converters.ts b/src/converters.ts index 47ae9d6..c6fa0ad 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -248,9 +248,9 @@ function shortenGenerateToolCallId(value: string): string { /** * Map every tool-call id referenced in this request to its generate-wire - * form. Short ids pass through unchanged; overlong ids are shortened - * deterministically, and on collision the later id keeps the next free - * `_` suffix so every distinct id still maps to a distinct value. + * form. Short ids are reserved first and pass through unchanged; overlong + * ids are shortened deterministically, and on collision receive the next + * free `_` suffix so every distinct id still maps to a distinct value. */ function generateToolCallIdMap(messages?: readonly MessageLike[]): Map { const referenced = new Set() @@ -269,7 +269,15 @@ function generateToolCallIdMap(messages?: readonly MessageLike[]): Map() const mapped = new Map() + // Reserve existing short ids before any long id can claim their wire value. for (const id of referenced) { + if (id.length <= GENERATE_TOOL_CALL_ID_LIMIT) { + mapped.set(id, id) + used.add(id) + } + } + for (const id of referenced) { + if (mapped.has(id)) continue let candidate = shortenGenerateToolCallId(id) if (used.has(candidate)) { const base = candidate.replace(/_+$/, "") diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 5b8e063..851dc59 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -1047,6 +1047,43 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), "c1") }) + for (const longFirst of [true, false]) { + it(`preserves a colliding short id when the ${longFirst ? "long" : "short"} id comes first`, () => { + const longId = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const standalone = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: longId, name: "bash", arguments: {} }], + }, + ]) + const shortId = String(objectAt(standalone, ["0", "content", "0", "toolCallId"])) + assert.equal(shortId.length, 64) + const ids = longFirst ? [longId, shortId] : [shortId, longId] + const result = messagesToCC([ + { + role: "assistant", + content: ids.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })), + }, + ...ids.map((id) => ({ + role: "toolResult", + toolCallId: id, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + })), + ]) + + const wireIds = ids.map((_, index) => + String(objectAt(result, ["0", "content", String(index), "toolCallId"])), + ) + assert.equal(wireIds[ids.indexOf(shortId)], shortId) + assert.equal(new Set(wireIds).size, ids.length) + for (const [index, wireId] of wireIds.entries()) { + assert.ok(wireId.length <= 64) + assert.equal(objectAt(result, [String(index + 1), "content", "0", "toolCallId"]), wireId) + } + }) + } + it("keeps distinct overlong tool-call ids distinct on the generate wire", () => { const first = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` const second = `call_${"a".repeat(31)}c|fc_${"b".repeat(32)}`