diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9c83bce85840..f07bea3e925c 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"), }); @@ -3225,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), 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)