From ca846dddbccf983649e5d2b01e9b292b5d7f88b5 Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 3 Oct 2026 23:36:42 +0300 Subject: [PATCH] fix(server): blank question option descriptions no longer fail the run Claude's AskUserQuestion and Pi's extension select dialogs can produce an option with an empty description. The user-input question contract requires a non-empty one, so the turn item failed to decode during event ingestion and the run was marked failed while the provider still waited for the answer. Fall back to the option label, as the Codex and OpenCode adapters already do. Fixes #15316 --- .../Adapters/ClaudeAdapterV2.test.ts | 30 ++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 11 ++---- .../Adapters/PiAdapterV2.test.ts | 39 +++++++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 6 ++- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b837575c1faf..f51792e7107d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -19,6 +19,7 @@ import { NodeId, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, + OrchestrationV2UserInputQuestion, ProjectId, ProviderInstanceId, type ProviderApprovalDecision, @@ -118,6 +119,10 @@ function makeClaudeTestAppThread(input: { }; } +const decodeUserInputQuestions = Schema.decodeUnknownExit( + Schema.Array(OrchestrationV2UserInputQuestion), +); + function makeClaudeTestTurnInput(input: { readonly threadId: ThreadId; readonly providerThread: OrchestrationV2ProviderThread; @@ -268,6 +273,31 @@ describe("ClaudeAdapterV2 runtime query policy", () => { ); }); + it("fills blank AskUserQuestion option descriptions from the label", () => { + // A question with a blank description used to fail event ingestion, which + // failed the run while Claude still waited for the answer. + const questions = ClaudeAdapterV2.claudeUserInputQuestions({ + questions: [ + { + header: "Naming", + question: "Which name?", + options: [ + { label: "Recorder", description: "Matches the package name." }, + { label: "Keep", description: "" }, + { label: "Other", description: " " }, + { label: "Ask later" }, + ], + multiSelect: false, + }, + ], + }); + assert.deepEqual( + questions[0]?.options.map((option) => option.description), + ["Matches the package name.", "Keep", "Other", "Ask later"], + ); + assert.isTrue(Exit.isSuccess(decodeUserInputQuestions(questions))); + }); + it("normalizes Claude todo and proposed-plan tool input", () => { assert.deepEqual( ClaudeAdapterV2.claudeTodoSteps({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0aca36abe77c..ea34d724b42d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2840,13 +2840,10 @@ export function claudeUserInputQuestions( const optionRecord = option as Record; const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : ""; if (label.length === 0) return []; - return [ - { - label, - description: - typeof optionRecord.description === "string" ? optionRecord.description.trim() : "", - }, - ]; + const description = + typeof optionRecord.description === "string" ? optionRecord.description.trim() : ""; + // The question contract requires a description; Claude may send it blank. + return [{ label, description: description.length > 0 ? description : label }]; }) : []; return [ diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index fb912714f12f..b4f4bf23a544 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -15,11 +15,13 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + OrchestrationV2UserInputQuestion, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -50,6 +52,9 @@ const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { const testLayer = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, serverConfigLayer); const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeUserInputQuestions = Schema.decodeUnknownExit( + Schema.Array(OrchestrationV2UserInputQuestion), +); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const PI_INSTANCE_ID = ProviderInstanceId.make("pi"); @@ -1446,6 +1451,40 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("describes an empty extension select option", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-select", + method: "select", + title: "Pick a tag", + options: ["stable", ""], + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + assert.deepEqual( + event.turnItem.questions[0]?.options.map((option) => [option.label, option.value]), + [ + ["stable", "stable"], + ["Empty value", ""], + ], + ); + assert.isTrue(Exit.isSuccess(decodeUserInputQuestions(event.turnItem.questions))); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("raises bridge edit confirmations as file-change approvals", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 6f0bcf258acb..103e08da1bad 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -2900,7 +2900,11 @@ function piQuestion( method === "select" && Array.isArray(event["options"]) ? event["options"] .filter((option): option is string => typeof option === "string") - .map((option) => ({ label: option || "Empty value", description: option, value: option })) + .map((option) => ({ + label: option || "Empty value", + description: option || "Empty value", + value: option, + })) : [ { label: "Submit empty value",