From b8c552e81c20d0ed7bf79a26174f1226712ca439 Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Wed, 29 Apr 2026 14:39:55 +0200 Subject: [PATCH 1/2] fix(server): key AskUserQuestion answers by question text Claude SDK >= 2.1.121 changed `mapToolResultToToolResultBlockParam` to look up `AskUserQuestion` answers by the full question text. T3 was setting `UserInputQuestion.id` to `header`, so the UI's draft key (and therefore the answers record returned to the SDK) was the header instead of the question text. Every lookup missed and the model received an empty interpolation in `tool_result`. Use the full question text as the parsed `id` so the entire chain (UI drafts -> server -> SDK) keys answers consistently with what the SDK now expects. Fixes pingdotgg/t3code#2388 --- apps/server/src/provider/Layers/ClaudeAdapter.test.ts | 3 +++ apps/server/src/provider/Layers/ClaudeAdapter.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9c83bce85840..b3b821b3aee0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3191,6 +3191,9 @@ describe("ClaudeAdapterLive", () => { assert.equal(typeof requestId, "string"); assert.equal(requestedEvent.value.payload.questions.length, 1); assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?"); + // Regression for #2388: `id` must equal the full question text so the + // UI's draft-answer key matches what the SDK looks up downstream. + assert.equal(requestedEvent.value.payload.questions[0]?.id, "Which framework?"); assert.deepEqual(requestedEvent.value.providerRefs, { providerItemId: ProviderItemId.make("tool-ask-1"), }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 35f326af07bb..c154514e8642 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2549,10 +2549,14 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); // Parse questions from the SDK's AskUserQuestion input. + // `id` MUST equal the full question text — Claude SDK >= 2.1.121 looks + // up answers by question text in `mapToolResultToToolResultBlockParam`, + // so the key the UI uses to keep its draft answer must match the SDK's + // expected lookup key. See https://github.com/pingdotgg/t3code/issues/2388 const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; const questions: Array = rawQuestions.map( (q: Record, idx: number) => ({ - id: typeof q.header === "string" ? q.header : `q-${idx}`, + id: typeof q.question === "string" && q.question.length > 0 ? q.question : `q-${idx}`, header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, question: typeof q.question === "string" ? q.question : "", options: Array.isArray(q.options) From 1d6d905041bb925697fca8cb27b646eae8590eaf Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Wed, 29 Apr 2026 14:44:28 +0200 Subject: [PATCH 2/2] test(server): cover AskUserQuestion answers across SDK 2.1.119 and 2.1.121 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the adapter's `updatedInput.answers` payload through both Claude SDK iteration patterns we have observed in the wild: - 2.1.119 used a key-agnostic `Object.entries(answers)` map, so any key produced a non-empty `tool_result`. - 2.1.121 looks up `answers[question]` by full question text — keys that don't match are dropped, which is what caused #2388. Asserting both renderings stay non-empty locks in the keying contract on either side of the regression so we don't quietly break older CLIs or re-introduce the original bug. Refs pingdotgg/t3code#2388 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index b3b821b3aee0..f07bea3e925c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3228,6 +3228,34 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(updatedInput.answers, { "Which framework?": "React" }); // Original questions should be passed through. assert.deepEqual(updatedInput.questions, askInput.questions); + + // Compatibility check for #2388: the answers shape we hand to the SDK + // must produce a non-empty rendered tool_result on BOTH SDK iteration + // patterns we have seen, so we don't regress the issue and we don't + // break users still on the older Claude CLI. + const sdkAnswers = updatedInput.answers as Record; + const sdkQuestions = updatedInput.questions as ReadonlyArray<{ + readonly question: string; + }>; + + // Claude CLI 2.1.119 — key-agnostic Object.entries iteration. Any key + // works here, but it must at least round-trip into a non-empty string. + const v119Rendered = Object.entries(sdkAnswers) + .map(([key, value]) => `"${key}"="${String(value)}"`) + .join(", "); + assert.equal(v119Rendered, '"Which framework?"="React"'); + + // Claude CLI 2.1.121 — lookup by full question text. This is the path + // that regressed in #2388 when the answers were keyed by `header`. + const v121Rendered = sdkQuestions + .map(({ question }) => { + const answer = sdkAnswers[question]; + return answer === undefined ? null : `"${question}"="${String(answer)}"`; + }) + .filter((entry): entry is string => entry !== null) + .join(", "); + assert.notEqual(v121Rendered, "", "Expected non-empty SDK 2.1.121 tool_result (#2388)"); + assert.equal(v121Rendered, '"Which framework?"="React"'); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer),