From 6d57b7eb60a66491b98c82364a030b45dd26d2e1 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:21:22 +0530 Subject: [PATCH 01/15] feat(server): support multi-select elicitation forms and opt in Antigravity chat --- .../Adapters/AcpAdapterV2.test.ts | 170 ++++++++++++++++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 182 ++++++++++++++---- .../Adapters/AntigravityAdapterV2.test.ts | 12 +- .../Adapters/AntigravityAdapterV2.ts | 1 + .../acp/AntigravityAcpSupport.test.ts | 51 +++++ .../src/provider/acp/AntigravityAcpSupport.ts | 8 + 6 files changed, 386 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b129a90a3946..c2296b1dd220 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -84,6 +84,8 @@ import { acpPostSettleWakeShouldBuffer, acpProjectedCommandExitCode, acpToolCallDiffPatch, + elicitationContent, + parseElicitationQuestions, acpTurnStartShouldPreserveContinuation, makeAcpAdapterV2, type AcpAdapterV2ExtensionContext, @@ -177,6 +179,174 @@ describe("acpToolCallDiffPatch", () => { }); }); +describe("ACP elicitation question parsing and answer serialization", () => { + it("parses multi-select questions with items.oneOf options and forbids custom answers", () => { + const questions = parseElicitationQuestions({ + message: "Please select packages", + requestedSchema: { + type: "object", + properties: { + packages: { + type: "array", + title: "Selected Packages", + description: "Choose packages to install", + items: { + type: "string", + oneOf: [ + { const: "@t3tools/server", title: "Server", description: "Backend server" }, + { const: "@t3tools/desktop", title: "Desktop", description: "Desktop app" }, + ], + }, + }, + }, + required: ["packages"], + }, + }); + + assert.deepEqual(questions, [ + { + id: "packages", + header: "Selected Packages", + question: "Choose packages to install", + options: [ + { label: "Server", description: "Backend server", value: "@t3tools/server" }, + { label: "Desktop", description: "Desktop app", value: "@t3tools/desktop" }, + ], + multiSelect: true, + allowCustomAnswer: false, + required: true, + }, + ]); + }); + + it("parses multi-select questions with items.enum options", () => { + const questions = parseElicitationQuestions({ + message: "Pick environments", + requestedSchema: { + type: "object", + properties: { + envs: { + type: "array", + items: { + type: "string", + enum: ["staging", "production"], + }, + }, + }, + }, + }); + + assert.deepEqual(questions, [ + { + id: "envs", + header: "Question 1", + question: "Pick environments", + options: [ + { label: "staging", description: "staging", value: "staging" }, + { label: "production", description: "production", value: "production" }, + ], + multiSelect: true, + allowCustomAnswer: false, + }, + ]); + }); + + it("parses single-select questions with oneOf and enum options and preserves custom answer allowances", () => { + const questions = parseElicitationQuestions({ + message: "Choose deploy target", + requestedSchema: { + type: "object", + properties: { + target: { + title: "Deploy Target", + oneOf: [ + { const: "us-east-1", title: "US East" }, + { const: "eu-west-1", title: "EU West" }, + ], + }, + freeform: { + title: "Notes", + type: "string", + }, + confirmation: { + title: "Confirm", + type: "boolean", + }, + }, + }, + }); + + assert.deepEqual(questions, [ + { + id: "target", + header: "Deploy Target", + question: "Choose deploy target", + options: [ + { label: "US East", description: "US East", value: "us-east-1" }, + { label: "EU West", description: "EU West", value: "eu-west-1" }, + ], + allowCustomAnswer: false, + }, + { + id: "freeform", + header: "Notes", + question: "Choose deploy target", + options: [], + }, + { + id: "confirmation", + header: "Confirm", + question: "Choose deploy target", + options: [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ], + allowCustomAnswer: false, + }, + ]); + }); + + it("coerces answers in elicitationContent based on property schema types", () => { + const properties = { + isFast: { type: "boolean" }, + port: { type: "integer" }, + invalidPort: { type: "number" }, + tags: { type: "array" }, + singleTag: { type: "array" }, + title: { type: "string" }, + fromArray: { type: "string" }, + untyped: {}, + }; + + const content = elicitationContent( + { + isFast: "true", + port: "8080", + invalidPort: "not-a-number", + tags: ["a", "b"], + singleTag: "single", + title: "my-title", + fromArray: ["first", "second"], + untyped: "value", + unknownKey: "discarded", + }, + properties, + ); + + assert.deepEqual(content, { + isFast: true, + port: 8080, + tags: ["a", "b"], + singleTag: ["single"], + title: "my-title", + fromArray: "first", + untyped: "value", + }); + assert.isFalse("invalidPort" in content); + assert.isFalse("unknownKey" in content); + }); +}); + describe("ACP continuation ownership", () => { it("preserves a continuation offered during non-buffered carryover handling", () => { assert.isFalse( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 6b5b1b91fb01..97e5149fd52e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1105,17 +1105,154 @@ function selectAutoApprovedPermissionOption( ); } -function elicitationContent( +function parseChoiceOptions( + choiceSchemas: ReadonlyArray, +): Array<{ label: string; description: string; value: string }> { + const options: Array<{ label: string; description: string; value: string }> = []; + for (const entry of choiceSchemas) { + const entryRecord = unknownRecord(entry); + if (!entryRecord) continue; + const rawValue = entryRecord.const ?? entryRecord.value; + if (rawValue === undefined || rawValue === null) continue; + const valueStr = String(rawValue); + const rawLabel = entryRecord.title ?? entryRecord.label; + const labelStr = nonEmptyText(rawLabel, valueStr); + const rawDesc = entryRecord.description; + const descStr = nonEmptyText(rawDesc, labelStr); + options.push({ label: labelStr, description: descStr, value: valueStr }); + } + return options; +} + +function parseEnumOptions( + enumValues: ReadonlyArray, +): Array<{ label: string; description: string; value: string }> { + const options: Array<{ label: string; description: string; value: string }> = []; + for (const entry of enumValues) { + if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") { + const str = String(entry).trim(); + if (str.length > 0) { + options.push({ label: str, description: str, value: str }); + } + } + } + return options; +} + +export function parseElicitationQuestions(params: { + readonly message: string; + readonly requestedSchema?: unknown; +}): Array { + const requestedSchema = unknownRecord(params.requestedSchema); + const properties = unknownRecord(requestedSchema?.properties) ?? {}; + const requiredList = Array.isArray(requestedSchema?.required) + ? requestedSchema.required.filter((item): item is string => typeof item === "string") + : []; + const requiredKeys = new Set(requiredList); + return Object.entries(properties).map( + ([id, property], index): OrchestrationV2UserInputQuestion => { + const record = unknownRecord(property); + const isArray = record?.type === "array"; + let options: Array<{ label: string; description: string; value?: string }> = []; + let multiSelect: boolean | undefined = undefined; + + if (isArray) { + multiSelect = true; + const itemsRecord = unknownRecord(record?.items); + const choiceList = itemsRecord?.oneOf ?? itemsRecord?.anyOf; + if (Array.isArray(choiceList)) { + options = parseChoiceOptions(choiceList); + } else if (Array.isArray(itemsRecord?.enum)) { + options = parseEnumOptions(itemsRecord.enum); + } + } else if (record?.type === "boolean") { + options = [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ]; + } else { + const choiceList = record?.oneOf ?? record?.anyOf; + if (Array.isArray(choiceList)) { + options = parseChoiceOptions(choiceList); + } else if (Array.isArray(record?.enum)) { + options = parseEnumOptions(record.enum); + } + } + + const allowCustomAnswer = + options.length > 0 || record?.type === "boolean" ? false : undefined; + + return { + id, + header: nonEmptyText(record?.title, `Question ${index + 1}`), + question: nonEmptyText(record?.description, params.message), + options, + ...(multiSelect ? { multiSelect: true } : {}), + ...(allowCustomAnswer !== undefined ? { allowCustomAnswer } : {}), + ...(requiredKeys.has(id) ? { required: true } : {}), + }; + }, + ); +} + +export function elicitationContent( answers: ProviderUserInputAnswers, - allowedKeys: ReadonlySet, + properties: Record, ): Record { const content: Record = {}; - for (const [key, value] of Object.entries(answers)) { - if (!allowedKeys.has(key)) continue; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - content[key] = value; - } else if (Array.isArray(value)) { - content[key] = value.filter((entry): entry is string => typeof entry === "string"); + for (const [key, rawValue] of Object.entries(answers)) { + if (!Object.prototype.hasOwnProperty.call(properties, key)) continue; + const propSchema = unknownRecord(properties[key]); + const declaredType = typeof propSchema?.type === "string" ? propSchema.type : undefined; + + if (declaredType === "boolean") { + const first = Array.isArray(rawValue) ? rawValue[0] : rawValue; + if (typeof first === "boolean") { + content[key] = first; + } else if (first === "true") { + content[key] = true; + } else if (first === "false") { + content[key] = false; + } + } else if (declaredType === "number" || declaredType === "integer") { + const first = Array.isArray(rawValue) ? rawValue[0] : rawValue; + const num = + typeof first === "number" + ? first + : typeof first === "string" && first.trim().length > 0 + ? Number(first) + : NaN; + if (!Number.isNaN(num) && Number.isFinite(num)) { + content[key] = num; + } + } else if (declaredType === "array") { + if (Array.isArray(rawValue)) { + content[key] = rawValue + .filter( + (entry): entry is string | number | boolean => entry !== null && entry !== undefined, + ) + .map(String); + } else if (typeof rawValue === "string" && rawValue.length > 0) { + content[key] = [rawValue]; + } + } else if (declaredType === "string") { + if (typeof rawValue === "string") { + content[key] = rawValue; + } else if (Array.isArray(rawValue) && rawValue.length > 0) { + content[key] = String(rawValue[0]); + } else if (typeof rawValue === "number" || typeof rawValue === "boolean") { + content[key] = String(rawValue); + } + } else { + if (typeof rawValue === "string" || typeof rawValue === "boolean") { + content[key] = rawValue; + } else if (typeof rawValue === "number") { + if (!Number.isNaN(rawValue) && Number.isFinite(rawValue)) { + content[key] = rawValue; + } + } else if (Array.isArray(rawValue)) { + content[key] = rawValue.filter((entry): entry is string => typeof entry === "string"); + } } } return content; @@ -5833,29 +5970,7 @@ export function makeAcpAdapterV2( const properties = unknownRecord(requestedSchema?.properties) ?? {}; const elicitationScopeId = "sessionId" in params ? params.sessionId : `request:${params.requestId}`; - const questions = Object.entries(properties).map( - ([id, property], index): OrchestrationV2UserInputQuestion => { - const record = unknownRecord(property); - const enumValues = Array.isArray(record?.enum) - ? record.enum.filter((value): value is string => typeof value === "string") - : []; - const options = - enumValues.length > 0 - ? enumValues.map((value) => ({ label: value, description: value })) - : record?.type === "boolean" - ? [ - { label: "true", description: "Yes" }, - { label: "false", description: "No" }, - ] - : []; - return { - id, - header: nonEmptyText(record?.title, `Question ${index + 1}`), - question: nonEmptyText(record?.description, params.message), - options, - }; - }, - ); + const questions = parseElicitationQuestions(params); const userInput = yield* requestUserInputWithAdmission( handlerGeneration, Effect.gen(function* () { @@ -5877,10 +5992,7 @@ export function makeAcpAdapterV2( ? ({ action: "cancel" } as const) : ({ action: "accept", - content: elicitationContent( - userInput.answers, - new Set(Object.keys(properties)), - ), + content: elicitationContent(userInput.answers, properties), } as const); yield* userInput.acknowledgeNativeResponse; return response; diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index d6d16646f993..19c190de952d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -148,6 +148,9 @@ describe("AntigravityAdapterV2 client file system", () => { // effect-acp keeps the last handler registered per method; so does this. let readTextFile: Parameters[0] | undefined; let writeTextFile: Parameters[0] | undefined; + let runtimeInput: + | Parameters[0]["makeRuntime"]>[0] + | undefined; const crypto = yield* Crypto.Crypto; const instanceId = ProviderInstanceId.make("antigravity-containment-test"); const adapter = makeAntigravityAdapterV2({ @@ -158,8 +161,9 @@ describe("AntigravityAdapterV2 client file system", () => { path, idAllocator: yield* IdAllocator.IdAllocatorV2, serverConfig, - makeRuntime: (input) => - makeAntigravityAcpRuntime({ + makeRuntime: (input) => { + runtimeInput = input; + return makeAntigravityAcpRuntime({ ...input, childProcessSpawner, spawn: { @@ -181,7 +185,8 @@ describe("AntigravityAdapterV2 client file system", () => { writeTextFile = handler; }).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))), })), - ), + ); + }, withProcess: (_stop, task) => task, defaultModel: Effect.succeed(undefined), }); @@ -210,6 +215,7 @@ describe("AntigravityAdapterV2 client file system", () => { runtimePolicy, }); yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); + assert.isTrue(runtimeInput?.elicitation); if (readTextFile === undefined || writeTextFile === undefined) { return yield* Effect.die("Antigravity sessions must serve client file requests"); } diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 1ee304556cc1..d78f2b9c34b6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -139,6 +139,7 @@ export function makeAntigravityAcpAdapterFlavor( options.makeRuntime({ ...input, clientFileSystem: true, + elicitation: true, additionalDirectories: [options.serverConfig.attachmentsDir], }), ) diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 8bb03d3469cb..f06ee548c875 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -13,10 +13,13 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { antigravityPermissionMode, applyAntigravityAcpModelSelection, buildAntigravityPrompt, + makeAntigravityAcpRuntime, } from "./AntigravityAcpSupport.ts"; const modelConfig = { @@ -601,3 +604,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); }); + +it.layer(NodeServices.layer)("makeAntigravityAcpRuntime elicitation capability", (it) => { + it.effect("advertises elicitation.form capability only when opt-in is enabled", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + + const inspectCapabilities = (elicitation?: boolean) => + Effect.gen(function* () { + const requestEvents: Array = []; + const runtime = yield* makeAntigravityAcpRuntime({ + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: process.cwd(), + env: { T3_ACP_ANTIGRAVITY: "1" }, + }, + clientInfo: { name: "t3-code", version: "0.0.0" }, + childProcessSpawner, + cwd: process.cwd(), + clientFileSystem: true, + ...(elicitation !== undefined ? { elicitation } : {}), + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }); + yield* runtime.start(); + const initEvent = requestEvents.find( + (e) => e.method === "initialize" && e.status === "started", + ); + const payload = initEvent?.payload as + | { clientCapabilities?: { elicitation?: { form?: unknown } } } + | undefined; + return payload?.clientCapabilities?.elicitation; + }).pipe(Effect.scoped); + + const withoutElicitation = yield* inspectCapabilities(undefined); + expect(withoutElicitation).toBeUndefined(); + + const withElicitation = yield* inspectCapabilities(true); + expect(withElicitation).toEqual({ form: {} }); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index a3aa8005cfb1..7bf8eba020c4 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -48,6 +48,13 @@ export interface AntigravityAcpRuntimeInput extends Omit< * generation helpers leave it off so they never touch a workspace. */ readonly clientFileSystem?: boolean; + /** + * Advertise `elicitation.form` capability to the ACP agent. + * Chat sessions turn this on so the agent uses `session/elicitation` for + * interactive user forms/questions instead of falling back to permission requests. + * Installation validation and helper runtimes leave it off. + */ + readonly elicitation?: boolean; /** ACP `authenticate` method id. Defaults to the personal Google account flow. */ readonly authMethod?: AntigravityAuthMethod; } @@ -73,6 +80,7 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( writeTextFile: input.clientFileSystem === true, }, terminal: false, + ...(input.elicitation === true ? { elicitation: { form: {} } } : {}), }, transformStdout: makeAntigravityStdoutTransform( input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}, From 7873c63073637e142a7dc176ec01c03908234901 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:43:21 +0530 Subject: [PATCH 02/15] fix(server): harden ACP elicitation option sanitization and schema bounds - Skip empty choice option values and titles in parseChoiceOptions - Treat array schema with maxItems: 1 as single-select in parseElicitationQuestions - Enforce integer check, minimum, and maximum constraints on numeric answers - Enforce minItems and maxItems truncation on array answers in elicitationContent - Add comprehensive unit test coverage for choice filtering and bounds --- .../Adapters/AcpAdapterV2.test.ts | 84 ++++++++++++++++++- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 30 +++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c2296b1dd220..1bcdda779734 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -306,13 +306,81 @@ describe("ACP elicitation question parsing and answer serialization", () => { ]); }); - it("coerces answers in elicitationContent based on property schema types", () => { + it("skips empty choice options and does not emit blank values or labels", () => { + const questions = parseElicitationQuestions({ + message: "Select option", + requestedSchema: { + type: "object", + properties: { + choice: { + type: "string", + oneOf: [ + { const: "", title: "" }, + { const: " ", title: "Blank" }, + { const: "valid", title: "Valid Option", description: "Good choice" }, + ], + }, + }, + }, + }); + + assert.deepEqual(questions, [ + { + id: "choice", + header: "Question 1", + question: "Select option", + options: [{ label: "Valid Option", description: "Good choice", value: "valid" }], + allowCustomAnswer: false, + }, + ]); + }); + + it("parses array questions with maxItems: 1 as single-select", () => { + const questions = parseElicitationQuestions({ + message: "Pick exactly one from list", + requestedSchema: { + type: "object", + properties: { + singlePick: { + type: "array", + maxItems: 1, + items: { + type: "string", + enum: ["first", "second"], + }, + }, + }, + }, + }); + + assert.deepEqual(questions, [ + { + id: "singlePick", + header: "Question 1", + question: "Pick exactly one from list", + options: [ + { label: "first", description: "first", value: "first" }, + { label: "second", description: "second", value: "second" }, + ], + allowCustomAnswer: false, + }, + ]); + }); + + it("coerces answers in elicitationContent based on property schema types and constraints", () => { const properties = { isFast: { type: "boolean" }, port: { type: "integer" }, + floatPort: { type: "integer" }, + boundedNumber: { type: "number", minimum: 10, maximum: 50 }, + underMin: { type: "number", minimum: 10 }, + overMax: { type: "number", maximum: 50 }, invalidPort: { type: "number" }, tags: { type: "array" }, singleTag: { type: "array" }, + limitedTags: { type: "array", maxItems: 2 }, + minTagsPass: { type: "array", minItems: 2 }, + minTagsFail: { type: "array", minItems: 2 }, title: { type: "string" }, fromArray: { type: "string" }, untyped: {}, @@ -322,9 +390,16 @@ describe("ACP elicitation question parsing and answer serialization", () => { { isFast: "true", port: "8080", + floatPort: "3.5", + boundedNumber: "25", + underMin: "5", + overMax: "100", invalidPort: "not-a-number", tags: ["a", "b"], singleTag: "single", + limitedTags: ["tag1", "tag2", "tag3"], + minTagsPass: ["tag1", "tag2"], + minTagsFail: ["tag1"], title: "my-title", fromArray: ["first", "second"], untyped: "value", @@ -336,13 +411,20 @@ describe("ACP elicitation question parsing and answer serialization", () => { assert.deepEqual(content, { isFast: true, port: 8080, + boundedNumber: 25, tags: ["a", "b"], singleTag: ["single"], + limitedTags: ["tag1", "tag2"], + minTagsPass: ["tag1", "tag2"], title: "my-title", fromArray: "first", untyped: "value", }); + assert.isFalse("floatPort" in content); + assert.isFalse("underMin" in content); + assert.isFalse("overMax" in content); assert.isFalse("invalidPort" in content); + assert.isFalse("minTagsFail" in content); assert.isFalse("unknownKey" in content); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 97e5149fd52e..64222889a577 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1114,7 +1114,8 @@ function parseChoiceOptions( if (!entryRecord) continue; const rawValue = entryRecord.const ?? entryRecord.value; if (rawValue === undefined || rawValue === null) continue; - const valueStr = String(rawValue); + const valueStr = String(rawValue).trim(); + if (valueStr.length === 0) continue; const rawLabel = entryRecord.title ?? entryRecord.label; const labelStr = nonEmptyText(rawLabel, valueStr); const rawDesc = entryRecord.description; @@ -1157,7 +1158,9 @@ export function parseElicitationQuestions(params: { let multiSelect: boolean | undefined = undefined; if (isArray) { - multiSelect = true; + if (record?.maxItems !== 1) { + multiSelect = true; + } const itemsRecord = unknownRecord(record?.items); const choiceList = itemsRecord?.oneOf ?? itemsRecord?.anyOf; if (Array.isArray(choiceList)) { @@ -1223,17 +1226,34 @@ export function elicitationContent( ? Number(first) : NaN; if (!Number.isNaN(num) && Number.isFinite(num)) { - content[key] = num; + if (declaredType === "integer" && !Number.isInteger(num)) { + // Reject floats for integer fields + } else if (typeof propSchema?.minimum === "number" && num < propSchema.minimum) { + // Reject numbers below minimum + } else if (typeof propSchema?.maximum === "number" && num > propSchema.maximum) { + // Reject numbers above maximum + } else { + content[key] = num; + } } } else if (declaredType === "array") { + let items: string[] = []; if (Array.isArray(rawValue)) { - content[key] = rawValue + items = rawValue .filter( (entry): entry is string | number | boolean => entry !== null && entry !== undefined, ) .map(String); } else if (typeof rawValue === "string" && rawValue.length > 0) { - content[key] = [rawValue]; + items = [rawValue]; + } + if (typeof propSchema?.maxItems === "number" && items.length > propSchema.maxItems) { + items = items.slice(0, propSchema.maxItems); + } + if (typeof propSchema?.minItems === "number" && items.length < propSchema.minItems) { + // Discard answer if it fails the required minimum item count + } else { + content[key] = items; } } else if (declaredType === "string") { if (typeof rawValue === "string") { From 03bd7e90bd9c2541f660a9286257ae1210b0995b Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:57:23 +0530 Subject: [PATCH 03/15] fix(server): preserve choice whitespace, reject over-limit answers, decline on missing required fields --- .../Adapters/AcpAdapterV2.test.ts | 32 +++++++++++++++---- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 30 ++++++++++------- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 1bcdda779734..c93049f030c1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -306,7 +306,7 @@ describe("ACP elicitation question parsing and answer serialization", () => { ]); }); - it("skips empty choice options and does not emit blank values or labels", () => { + it("skips empty choice options and does not emit blank values or labels while preserving nonblank whitespace", () => { const questions = parseElicitationQuestions({ message: "Select option", requestedSchema: { @@ -317,9 +317,13 @@ describe("ACP elicitation question parsing and answer serialization", () => { oneOf: [ { const: "", title: "" }, { const: " ", title: "Blank" }, - { const: "valid", title: "Valid Option", description: "Good choice" }, + { const: " valid ", title: "Valid Option", description: "Good choice" }, ], }, + enumChoice: { + type: "string", + enum: [" ", " option with space "], + }, }, }, }); @@ -329,7 +333,20 @@ describe("ACP elicitation question parsing and answer serialization", () => { id: "choice", header: "Question 1", question: "Select option", - options: [{ label: "Valid Option", description: "Good choice", value: "valid" }], + options: [{ label: "Valid Option", description: "Good choice", value: " valid " }], + allowCustomAnswer: false, + }, + { + id: "enumChoice", + header: "Question 2", + question: "Select option", + options: [ + { + label: " option with space ", + description: " option with space ", + value: " option with space ", + }, + ], allowCustomAnswer: false, }, ]); @@ -378,7 +395,8 @@ describe("ACP elicitation question parsing and answer serialization", () => { invalidPort: { type: "number" }, tags: { type: "array" }, singleTag: { type: "array" }, - limitedTags: { type: "array", maxItems: 2 }, + limitedTagsFail: { type: "array", maxItems: 2 }, + limitedTagsPass: { type: "array", maxItems: 2 }, minTagsPass: { type: "array", minItems: 2 }, minTagsFail: { type: "array", minItems: 2 }, title: { type: "string" }, @@ -397,7 +415,8 @@ describe("ACP elicitation question parsing and answer serialization", () => { invalidPort: "not-a-number", tags: ["a", "b"], singleTag: "single", - limitedTags: ["tag1", "tag2", "tag3"], + limitedTagsFail: ["tag1", "tag2", "tag3"], + limitedTagsPass: ["tag1", "tag2"], minTagsPass: ["tag1", "tag2"], minTagsFail: ["tag1"], title: "my-title", @@ -414,7 +433,7 @@ describe("ACP elicitation question parsing and answer serialization", () => { boundedNumber: 25, tags: ["a", "b"], singleTag: ["single"], - limitedTags: ["tag1", "tag2"], + limitedTagsPass: ["tag1", "tag2"], minTagsPass: ["tag1", "tag2"], title: "my-title", fromArray: "first", @@ -424,6 +443,7 @@ describe("ACP elicitation question parsing and answer serialization", () => { assert.isFalse("underMin" in content); assert.isFalse("overMax" in content); assert.isFalse("invalidPort" in content); + assert.isFalse("limitedTagsFail" in content); assert.isFalse("minTagsFail" in content); assert.isFalse("unknownKey" in content); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 64222889a577..f2b83b716772 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1114,8 +1114,8 @@ function parseChoiceOptions( if (!entryRecord) continue; const rawValue = entryRecord.const ?? entryRecord.value; if (rawValue === undefined || rawValue === null) continue; - const valueStr = String(rawValue).trim(); - if (valueStr.length === 0) continue; + const valueStr = String(rawValue); + if (valueStr.trim().length === 0) continue; const rawLabel = entryRecord.title ?? entryRecord.label; const labelStr = nonEmptyText(rawLabel, valueStr); const rawDesc = entryRecord.description; @@ -1131,8 +1131,8 @@ function parseEnumOptions( const options: Array<{ label: string; description: string; value: string }> = []; for (const entry of enumValues) { if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") { - const str = String(entry).trim(); - if (str.length > 0) { + const str = String(entry); + if (str.trim().length > 0) { options.push({ label: str, description: str, value: str }); } } @@ -1248,9 +1248,8 @@ export function elicitationContent( items = [rawValue]; } if (typeof propSchema?.maxItems === "number" && items.length > propSchema.maxItems) { - items = items.slice(0, propSchema.maxItems); - } - if (typeof propSchema?.minItems === "number" && items.length < propSchema.minItems) { + // Discard answer if it exceeds the allowed maximum item count + } else if (typeof propSchema?.minItems === "number" && items.length < propSchema.minItems) { // Discard answer if it fails the required minimum item count } else { content[key] = items; @@ -6010,10 +6009,19 @@ export function makeAcpAdapterV2( const response = userInput.answers === null ? ({ action: "cancel" } as const) - : ({ - action: "accept", - content: elicitationContent(userInput.answers, properties), - } as const); + : (() => { + const content = elicitationContent(userInput.answers, properties); + const requiredKeys = Array.isArray(requestedSchema?.required) + ? requestedSchema.required.filter( + (item): item is string => typeof item === "string", + ) + : []; + return requiredKeys.some( + (key) => !Object.prototype.hasOwnProperty.call(content, key), + ) + ? ({ action: "decline" } as const) + : ({ action: "accept", content } as const); + })(); yield* userInput.acknowledgeNativeResponse; return response; }), From a62b460e9ec67ec1db4d6a21b5cd2e74c45d0707 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:53:19 +0530 Subject: [PATCH 04/15] fix(acp): bound multi-select answers and refine elicitation question hints - contracts: add minItems/maxItems to both user input question schemas - adapter: emit array bounds (non-negative integer guarded), surface numeric limits in question text, drop the unused required flag, extract resolveElicitationResponse, simplify finiteness checks, and document whitespace and validation scope - web/mobile: cap multi-select toggles at maxItems and show min/max hints - tests: cover bounds parsing, resolver cancel/accept/decline, selection guards; export Antigravity release constants for asset tests Co-authored-by: CommandCodeBot --- .../features/threads/PendingUserInputCard.tsx | 11 ++ apps/mobile/src/lib/threadActivity.test.ts | 13 +++ apps/mobile/src/lib/threadActivity.ts | 13 ++- .../Adapters/AcpAdapterV2.test.ts | 103 +++++++++++++++++- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 91 ++++++++++++---- .../provider/AntigravityInstallation.test.ts | 13 ++- .../acp/AntigravityAcpSupport.test.ts | 3 + .../server/src/provider/antigravityRelease.ts | 4 +- .../chat/ComposerPendingUserInputPanel.tsx | 10 +- apps/web/src/pendingUserInput.test.ts | 13 +++ apps/web/src/pendingUserInput.ts | 13 ++- packages/contracts/src/orchestrationV2.ts | 2 + packages/contracts/src/providerRuntime.ts | 2 + 13 files changed, 256 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index bd3cb03b17b6..d7021dedcacb 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -278,6 +278,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.question} + {question.multiSelect ? ( + + {question.minItems !== undefined && question.maxItems !== undefined + ? `Select between ${question.minItems} and ${question.maxItems} options.` + : question.maxItems !== undefined + ? `Select up to ${question.maxItems} option${question.maxItems === 1 ? "" : "s"}.` + : question.minItems !== undefined + ? `Select at least ${question.minItems} option${question.minItems === 1 ? "" : "s"}.` + : "Select one or more options."} + + ) : null} {question.options.map((option) => { const optionValue = option.value ?? option.label.trim(); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5471f24704a9..13dbbec76dab 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1977,6 +1977,19 @@ describe("pending user input answers", () => { ).toEqual({ customAnswer: "" }); }); + it("prevents selecting more options than question.maxItems while allowing deselection", () => { + const question = { ...multiSelectQuestion, maxItems: 2 }; + const one = togglePendingUserInputOptionSelection(question, undefined, "Orders"); + const two = togglePendingUserInputOptionSelection(question, one, "Listings"); + expect(two.selectedOptionValues).toEqual(["Orders", "Listings"]); + + const three = togglePendingUserInputOptionSelection(question, two, "Customers"); + expect(three.selectedOptionValues).toEqual(["Orders", "Listings"]); + + const deselected = togglePendingUserInputOptionSelection(question, two, "Orders"); + expect(deselected.selectedOptionValues).toEqual(["Listings"]); + }); + it("builds array answers for multi-select questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a77491795704..7e2eaf0eb300 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1607,7 +1607,18 @@ export function togglePendingUserInputOptionSelection( question, draft?.selectedOptionValues, ); - const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + const isSelected = selectedOptionValues.includes(resolvedOptionValue); + if ( + !isSelected && + question.maxItems !== undefined && + selectedOptionValues.length >= question.maxItems + ) { + return { + customAnswer: "", + ...(selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; + } + const nextSelectedOptionValues = isSelected ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) : [...selectedOptionValues, resolvedOptionValue]; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c93049f030c1..c153a81d1653 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -86,6 +86,7 @@ import { acpToolCallDiffPatch, elicitationContent, parseElicitationQuestions, + resolveElicitationResponse, acpTurnStartShouldPreserveContinuation, makeAcpAdapterV2, type AcpAdapterV2ExtensionContext, @@ -214,7 +215,6 @@ describe("ACP elicitation question parsing and answer serialization", () => { ], multiSelect: true, allowCustomAnswer: false, - required: true, }, ]); }); @@ -379,6 +379,7 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "first", description: "first", value: "first" }, { label: "second", description: "second", value: "second" }, ], + maxItems: 1, allowCustomAnswer: false, }, ]); @@ -447,6 +448,106 @@ describe("ACP elicitation question parsing and answer serialization", () => { assert.isFalse("minTagsFail" in content); assert.isFalse("unknownKey" in content); }); + + it("surfaces numeric bounds in question text and parses minItems/maxItems for array questions", () => { + const questions = parseElicitationQuestions({ + message: "Please configure settings", + requestedSchema: { + type: "object", + properties: { + boundedPort: { + type: "integer", + title: "Port", + description: "Target port", + minimum: 1024, + maximum: 65535, + }, + minTimeout: { + type: "number", + title: "Timeout", + minimum: 1, + }, + maxRetries: { + type: "integer", + title: "Retries", + maximum: 10, + }, + tags: { + type: "array", + title: "Tags", + minItems: 1, + maxItems: 5, + items: { + type: "string", + enum: ["tag1", "tag2"], + }, + }, + }, + }, + }); + + assert.equal(questions[0]?.question, "Target port (between 1024 and 65535)"); + assert.equal(questions[1]?.question, "Please configure settings (minimum 1)"); + assert.equal(questions[2]?.question, "Please configure settings (maximum 10)"); + assert.equal(questions[3]?.minItems, 1); + assert.equal(questions[3]?.maxItems, 5); + assert.isUndefined(questions[0]?.required); + assert.isUndefined(questions[3]?.required); + }); + + it("resolves elicitation responses for cancel, accept, and decline on missing required fields", () => { + const properties = { + name: { type: "string" }, + port: { type: "integer", minimum: 1024, maximum: 65535 }, + tags: { type: "array", maxItems: 2 }, + }; + + // 1. Cancel on null answers + const cancelResult = resolveElicitationResponse({ + answers: null, + properties, + requiredKeys: ["name"], + }); + assert.deepEqual(cancelResult, { action: "cancel" }); + + // 2. Accept when all required keys pass + const acceptResult = resolveElicitationResponse({ + answers: { name: "my-app", port: "8080", tags: ["a"] }, + properties, + requiredKeys: ["name", "port"], + }); + assert.deepEqual(acceptResult, { + action: "accept", + content: { name: "my-app", port: 8080, tags: ["a"] }, + }); + + // 3. Decline when a required property value violates schema constraint and gets omitted + const declineResult = resolveElicitationResponse({ + answers: { name: "my-app", port: "100" }, // 100 < minimum 1024 -> omitted + properties, + requiredKeys: ["port"], + }); + assert.deepEqual(declineResult, { action: "decline" }); + + // 4. Decline when an array exceeds maxItems on a required field + const declineArrayResult = resolveElicitationResponse({ + answers: { tags: ["a", "b", "c"] }, // 3 > maxItems 2 -> omitted + properties, + requiredKeys: ["tags"], + }); + assert.deepEqual(declineArrayResult, { action: "decline" }); + + // 5. Accept when only optional property value is omitted due to constraint violation + const acceptOptionalOmitResult = resolveElicitationResponse({ + answers: { name: "my-app", port: "100" }, // port violates, but port is optional + properties, + requiredKeys: ["name"], + }); + assert.deepEqual(acceptOptionalOmitResult, { + action: "accept", + content: { name: "my-app" }, + }); + }); }); describe("ACP continuation ownership", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index f2b83b716772..114d31664355 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1105,6 +1105,9 @@ function selectAutoApprovedPermissionOption( ); } +// Option labels are presentation-layer strings displayed in the UI where leading/trailing +// whitespace is trimmed and validated by TrimmedNonEmptyString. Option values remain byte-exact +// so that serialized JSON-RPC elicitation responses match the agent-declared schema values. function parseChoiceOptions( choiceSchemas: ReadonlyArray, ): Array<{ label: string; description: string; value: string }> { @@ -1125,6 +1128,8 @@ function parseChoiceOptions( return options; } +// Enum option labels match their raw string representation; like choice options, values +// are preserved byte-exact for JSON-RPC serialization. function parseEnumOptions( enumValues: ReadonlyArray, ): Array<{ label: string; description: string; value: string }> { @@ -1146,10 +1151,6 @@ export function parseElicitationQuestions(params: { }): Array { const requestedSchema = unknownRecord(params.requestedSchema); const properties = unknownRecord(requestedSchema?.properties) ?? {}; - const requiredList = Array.isArray(requestedSchema?.required) - ? requestedSchema.required.filter((item): item is string => typeof item === "string") - : []; - const requiredKeys = new Set(requiredList); return Object.entries(properties).map( ([id, property], index): OrchestrationV2UserInputQuestion => { const record = unknownRecord(property); @@ -1185,19 +1186,46 @@ export function parseElicitationQuestions(params: { const allowCustomAnswer = options.length > 0 || record?.type === "boolean" ? false : undefined; + let questionText = nonEmptyText(record?.description, params.message); + const declaredType = typeof record?.type === "string" ? record.type : undefined; + if (declaredType === "number" || declaredType === "integer") { + const hasMin = typeof record?.minimum === "number"; + const hasMax = typeof record?.maximum === "number"; + if (hasMin && hasMax) { + questionText = `${questionText} (between ${record!.minimum} and ${record!.maximum})`; + } else if (hasMin) { + questionText = `${questionText} (minimum ${record!.minimum})`; + } else if (hasMax) { + questionText = `${questionText} (maximum ${record!.maximum})`; + } + } + + const minItems = + isArray && Number.isInteger(record?.minItems) && (record!.minItems as number) >= 0 + ? (record!.minItems as number) + : undefined; + const maxItems = + isArray && Number.isInteger(record?.maxItems) && (record!.maxItems as number) >= 0 + ? (record!.maxItems as number) + : undefined; + return { id, header: nonEmptyText(record?.title, `Question ${index + 1}`), - question: nonEmptyText(record?.description, params.message), + question: questionText, options, ...(multiSelect ? { multiSelect: true } : {}), + ...(minItems !== undefined ? { minItems } : {}), + ...(maxItems !== undefined ? { maxItems } : {}), ...(allowCustomAnswer !== undefined ? { allowCustomAnswer } : {}), - ...(requiredKeys.has(id) ? { required: true } : {}), }; }, ); } +// Advanced JSON Schema validation keywords (such as exclusiveMinimum, exclusiveMaximum, +// pattern, minLength, and maxLength) are intentionally scoped out here; conversational elicitation +// focuses on core interactive primitives (strings, numbers, booleans, choices, and arrays). export function elicitationContent( answers: ProviderUserInputAnswers, properties: Record, @@ -1225,7 +1253,7 @@ export function elicitationContent( : typeof first === "string" && first.trim().length > 0 ? Number(first) : NaN; - if (!Number.isNaN(num) && Number.isFinite(num)) { + if (Number.isFinite(num)) { if (declaredType === "integer" && !Number.isInteger(num)) { // Reject floats for integer fields } else if (typeof propSchema?.minimum === "number" && num < propSchema.minimum) { @@ -1266,7 +1294,7 @@ export function elicitationContent( if (typeof rawValue === "string" || typeof rawValue === "boolean") { content[key] = rawValue; } else if (typeof rawValue === "number") { - if (!Number.isNaN(rawValue) && Number.isFinite(rawValue)) { + if (Number.isFinite(rawValue)) { content[key] = rawValue; } } else if (Array.isArray(rawValue)) { @@ -1277,6 +1305,27 @@ export function elicitationContent( return content; } +export function resolveElicitationResponse(params: { + readonly answers: ProviderUserInputAnswers | null; + readonly properties: Record; + readonly requiredKeys?: ReadonlyArray; +}): + | { readonly action: "cancel" } + | { readonly action: "decline" } + | { + readonly action: "accept"; + readonly content: Record; + } { + if (params.answers === null) { + return { action: "cancel" }; + } + const content = elicitationContent(params.answers, params.properties); + const requiredKeys = params.requiredKeys ?? []; + return requiredKeys.some((key) => !Object.prototype.hasOwnProperty.call(content, key)) + ? { action: "decline" } + : { action: "accept", content }; +} + interface ActiveTextSegment { readonly nativeItemId: string; readonly startedAt: DateTime.Utc; @@ -6006,22 +6055,16 @@ export function makeAcpAdapterV2( }), transportRequestId, ); - const response = - userInput.answers === null - ? ({ action: "cancel" } as const) - : (() => { - const content = elicitationContent(userInput.answers, properties); - const requiredKeys = Array.isArray(requestedSchema?.required) - ? requestedSchema.required.filter( - (item): item is string => typeof item === "string", - ) - : []; - return requiredKeys.some( - (key) => !Object.prototype.hasOwnProperty.call(content, key), - ) - ? ({ action: "decline" } as const) - : ({ action: "accept", content } as const); - })(); + const requiredKeys = Array.isArray(requestedSchema?.required) + ? requestedSchema.required.filter( + (item): item is string => typeof item === "string", + ) + : []; + const response = resolveElicitationResponse({ + answers: userInput.answers, + properties, + requiredKeys, + }); yield* userInput.acknowledgeNativeResponse; return response; }), diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index be0fa4f00b16..c8f5bb77697d 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -26,6 +26,8 @@ import * as NodeCrypto from "node:crypto"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; import { + ANTIGRAVITY_RELEASE_VERSION, + releaseAssets, resolveAntigravityReleaseAsset, type AntigravityReleaseAsset, } from "./antigravityRelease.ts"; @@ -962,12 +964,13 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { for (const { platform, arch } of supportedPlatforms) { const asset = resolveAntigravityReleaseAsset(platform, arch); + const expected = releaseAssets.get(`${platform}-${arch}`); expect(asset).not.toBeNull(); - expect(asset?.version).toBe("1.3.0"); - expect(asset?.url).toContain("1.3.0"); - expect(asset?.archiveBytes).toBeGreaterThan(0); - expect(asset?.executable.bytes).toBeGreaterThan(0); - expect(asset?.harness.bytes).toBeGreaterThan(0); + expect(asset?.version).toBe(ANTIGRAVITY_RELEASE_VERSION); + expect(asset?.url).toContain(ANTIGRAVITY_RELEASE_VERSION); + expect(asset?.archiveBytes).toBe(expected?.archiveBytes); + expect(asset?.executable.bytes).toBe(expected?.executable.bytes); + expect(asset?.harness.bytes).toBe(expected?.harness.bytes); } expect(resolveAntigravityReleaseAsset("freebsd", "x64")).toBeNull(); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index f06ee548c875..821153221187 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -647,6 +647,9 @@ it.layer(NodeServices.layer)("makeAntigravityAcpRuntime elicitation capability", const withoutElicitation = yield* inspectCapabilities(undefined); expect(withoutElicitation).toBeUndefined(); + const withFalseElicitation = yield* inspectCapabilities(false); + expect(withFalseElicitation).toBeUndefined(); + const withElicitation = yield* inspectCapabilities(true); expect(withElicitation).toEqual({ form: {} }); }), diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index bcce891a463e..8190b3d9c18d 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,4 @@ -const ANTIGRAVITY_RELEASE_VERSION = "1.3.0"; +export const ANTIGRAVITY_RELEASE_VERSION = "1.3.0"; export interface AntigravityReleaseAsset { readonly version: string; @@ -17,7 +17,7 @@ export interface AntigravityReleaseAsset { // URLs come from the official registry. Hashes and sizes were checked on 2026-10-05. // https://github.com/agentclientprotocol/registry/blob/dc55a34900fdd60e5e97c1cbd7825c5a1df673fc/antigravity-acp/agent.json -const releaseAssets = new Map([ +export const releaseAssets = new Map([ [ "darwin-arm64", { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 28124ce74aad..7f92e9584584 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -235,7 +235,15 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(

{activeQuestion.question}

{activeQuestion.multiSelect ? ( -

Select one or more options.

+

+ {activeQuestion.minItems !== undefined && activeQuestion.maxItems !== undefined + ? `Select between ${activeQuestion.minItems} and ${activeQuestion.maxItems} options.` + : activeQuestion.maxItems !== undefined + ? `Select up to ${activeQuestion.maxItems} option${activeQuestion.maxItems === 1 ? "" : "s"}.` + : activeQuestion.minItems !== undefined + ? `Select at least ${activeQuestion.minItems} option${activeQuestion.minItems === 1 ? "" : "s"}.` + : "Select one or more options."} +

) : null}
{activeQuestion.options.map((option, index) => { diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 24f1be6fa602..dec55379d687 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -151,6 +151,19 @@ describe("togglePendingUserInputOptionSelection", () => { result: ["second"], }); }); + + it("prevents selecting more options than question.maxItems while allowing deselection", () => { + const question = { ...multiSelectQuestion, maxItems: 2 }; + const one = togglePendingUserInputOptionSelection(question, undefined, "Server"); + const two = togglePendingUserInputOptionSelection(question, one, "Desktop"); + expect(two.selectedOptionValues).toEqual(["Server", "Desktop"]); + + const three = togglePendingUserInputOptionSelection(question, two, "Web"); + expect(three.selectedOptionValues).toEqual(["Server", "Desktop"]); + + const deselected = togglePendingUserInputOptionSelection(question, two, "Server"); + expect(deselected.selectedOptionValues).toEqual(["Desktop"]); + }); }); describe("buildPendingUserInputAnswers", () => { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 538909d13d69..1e2f9c869a92 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -111,7 +111,18 @@ export function togglePendingUserInputOptionSelection( ): PendingUserInputDraftAnswer { if (question.multiSelect) { const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); - const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) + const isSelected = selectedOptionValues.includes(optionValue); + if ( + !isSelected && + question.maxItems !== undefined && + selectedOptionValues.length >= question.maxItems + ) { + return { + customAnswer: "", + ...(selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; + } + const nextSelectedOptionValues = isSelected ? selectedOptionValues.filter((value) => value !== optionValue) : [...selectedOptionValues, optionValue]; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 8107e1ed1c20..4d2ab1a63b4e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1108,6 +1108,8 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ }), ), multiSelect: Schema.optional(Schema.Boolean), + minItems: Schema.optional(NonNegativeInt), + maxItems: Schema.optional(NonNegativeInt), allowCustomAnswer: Schema.optional(Schema.Boolean), required: Schema.optional(Schema.Boolean), }); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5645d230a47a..73866dfe3060 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -494,6 +494,8 @@ export const UserInputQuestion = Schema.Struct({ multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), ), + minItems: Schema.optional(NonNegativeInt), + maxItems: Schema.optional(NonNegativeInt), }); export type UserInputQuestion = typeof UserInputQuestion.Type; From 248703a0d5b892893180b5808a6a3bcdc1670a3a Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:01:12 +0530 Subject: [PATCH 05/15] feat(acp): validate elicitation answers at dispatch and carry schema constraints - contracts: carry valueType, numeric bounds, string lengths/pattern, and item counts on user input questions, plus a shared pure validator (normalizeUserInputAnswer / validateUserInputAnswers) - adapter: emit constraints and explicit required true|false, clamp optionless array minItems to what a typed answer can satisfy, build content through the shared normalizer, and keep the decline path as an unreachable backstop - orchestrator: reject invalid live answers before anything commits so the request stays pending and the form stays open for correction - tests: validator unit suite, question emission and content coercion cases, optionless clamp, and a dispatch reject-then-retry integration test Co-authored-by: CommandCodeBot --- .../Adapters/AcpAdapterV2.test.ts | 167 ++++++++++---- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 157 ++++++-------- .../Orchestrator.control-reads.test.ts | 138 ++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 24 +++ packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestrationV2.ts | 8 + packages/contracts/src/providerRuntime.ts | 9 + .../contracts/src/userInputValidation.test.ts | 148 +++++++++++++ packages/contracts/src/userInputValidation.ts | 203 ++++++++++++++++++ 9 files changed, 724 insertions(+), 131 deletions(-) create mode 100644 packages/contracts/src/userInputValidation.test.ts create mode 100644 packages/contracts/src/userInputValidation.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c153a81d1653..e6401c5509d1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -181,6 +181,16 @@ describe("acpToolCallDiffPatch", () => { }); describe("ACP elicitation question parsing and answer serialization", () => { + type ElicitationQuestion = ReturnType[number]; + const elicitationQuestion = ( + overrides: Partial & { readonly id: string }, + ): ElicitationQuestion => ({ + header: "Question", + question: "Question", + options: [], + ...overrides, + }); + it("parses multi-select questions with items.oneOf options and forbids custom answers", () => { const questions = parseElicitationQuestions({ message: "Please select packages", @@ -213,8 +223,10 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "Server", description: "Backend server", value: "@t3tools/server" }, { label: "Desktop", description: "Desktop app", value: "@t3tools/desktop" }, ], + valueType: "array", multiSelect: true, allowCustomAnswer: false, + required: true, }, ]); }); @@ -245,8 +257,10 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "staging", description: "staging", value: "staging" }, { label: "production", description: "production", value: "production" }, ], + valueType: "array", multiSelect: true, allowCustomAnswer: false, + required: false, }, ]); }); @@ -286,12 +300,15 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "EU West", description: "EU West", value: "eu-west-1" }, ], allowCustomAnswer: false, + required: false, }, { id: "freeform", header: "Notes", question: "Choose deploy target", options: [], + valueType: "string", + required: false, }, { id: "confirmation", @@ -301,7 +318,9 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "true", description: "Yes", value: "true" }, { label: "false", description: "No", value: "false" }, ], + valueType: "boolean", allowCustomAnswer: false, + required: false, }, ]); }); @@ -334,7 +353,9 @@ describe("ACP elicitation question parsing and answer serialization", () => { header: "Question 1", question: "Select option", options: [{ label: "Valid Option", description: "Good choice", value: " valid " }], + valueType: "string", allowCustomAnswer: false, + required: false, }, { id: "enumChoice", @@ -347,7 +368,9 @@ describe("ACP elicitation question parsing and answer serialization", () => { value: " option with space ", }, ], + valueType: "string", allowCustomAnswer: false, + required: false, }, ]); }); @@ -379,31 +402,72 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "first", description: "first", value: "first" }, { label: "second", description: "second", value: "second" }, ], + valueType: "array", maxItems: 1, allowCustomAnswer: false, + required: false, }, ]); }); + it("clamps minimum item counts for optionless arrays to what a typed answer can satisfy", () => { + const questions = parseElicitationQuestions({ + message: "Add tags", + requestedSchema: { + type: "object", + properties: { + tags: { type: "array", minItems: 3, maxItems: 0 }, + }, + }, + }); + + assert.equal(questions[0]?.minItems, 1); + assert.equal(questions[0]?.maxItems, 1); + const content = elicitationContent({ tags: "solo" }, questions); + assert.deepEqual(content, { tags: ["solo"] }); + }); + it("coerces answers in elicitationContent based on property schema types and constraints", () => { - const properties = { - isFast: { type: "boolean" }, - port: { type: "integer" }, - floatPort: { type: "integer" }, - boundedNumber: { type: "number", minimum: 10, maximum: 50 }, - underMin: { type: "number", minimum: 10 }, - overMax: { type: "number", maximum: 50 }, - invalidPort: { type: "number" }, - tags: { type: "array" }, - singleTag: { type: "array" }, - limitedTagsFail: { type: "array", maxItems: 2 }, - limitedTagsPass: { type: "array", maxItems: 2 }, - minTagsPass: { type: "array", minItems: 2 }, - minTagsFail: { type: "array", minItems: 2 }, - title: { type: "string" }, - fromArray: { type: "string" }, - untyped: {}, - }; + const questions = parseElicitationQuestions({ + message: "Configure", + requestedSchema: { + type: "object", + properties: { + isFast: { type: "boolean" }, + port: { type: "integer" }, + floatPort: { type: "integer" }, + boundedNumber: { type: "number", minimum: 10, maximum: 50 }, + underMin: { type: "number", minimum: 10 }, + overMax: { type: "number", maximum: 50 }, + invalidPort: { type: "number" }, + tags: { type: "array" }, + singleTag: { type: "array" }, + limitedTagsFail: { + type: "array", + maxItems: 2, + items: { type: "string", enum: ["tag1", "tag2", "tag3"] }, + }, + limitedTagsPass: { + type: "array", + maxItems: 2, + items: { type: "string", enum: ["tag1", "tag2"] }, + }, + minTagsPass: { + type: "array", + minItems: 2, + items: { type: "string", enum: ["tag1", "tag2"] }, + }, + minTagsFail: { + type: "array", + minItems: 2, + items: { type: "string", enum: ["tag1", "tag2"] }, + }, + title: { type: "string" }, + fromArray: { type: "string" }, + untyped: {}, + }, + }, + }); const content = elicitationContent( { @@ -425,7 +489,7 @@ describe("ACP elicitation question parsing and answer serialization", () => { untyped: "value", unknownKey: "discarded", }, - properties, + questions, ); assert.deepEqual(content, { @@ -489,59 +553,84 @@ describe("ACP elicitation question parsing and answer serialization", () => { assert.equal(questions[0]?.question, "Target port (between 1024 and 65535)"); assert.equal(questions[1]?.question, "Please configure settings (minimum 1)"); assert.equal(questions[2]?.question, "Please configure settings (maximum 10)"); + assert.equal(questions[0]?.valueType, "integer"); + assert.equal(questions[0]?.minimum, 1024); + assert.equal(questions[0]?.maximum, 65535); + assert.equal(questions[1]?.valueType, "number"); + assert.equal(questions[1]?.minimum, 1); + assert.equal(questions[3]?.valueType, "array"); assert.equal(questions[3]?.minItems, 1); assert.equal(questions[3]?.maxItems, 5); - assert.isUndefined(questions[0]?.required); - assert.isUndefined(questions[3]?.required); + assert.isFalse(questions[0]?.required); + assert.isFalse(questions[3]?.required); }); it("resolves elicitation responses for cancel, accept, and decline on missing required fields", () => { - const properties = { - name: { type: "string" }, - port: { type: "integer", minimum: 1024, maximum: 65535 }, - tags: { type: "array", maxItems: 2 }, - }; - // 1. Cancel on null answers const cancelResult = resolveElicitationResponse({ answers: null, - properties, - requiredKeys: ["name"], + questions: [elicitationQuestion({ id: "name", valueType: "string", required: true })], }); assert.deepEqual(cancelResult, { action: "cancel" }); // 2. Accept when all required keys pass const acceptResult = resolveElicitationResponse({ answers: { name: "my-app", port: "8080", tags: ["a"] }, - properties, - requiredKeys: ["name", "port"], + questions: [ + elicitationQuestion({ id: "name", valueType: "string", required: true }), + elicitationQuestion({ + id: "port", + valueType: "integer", + minimum: 1024, + maximum: 65535, + required: true, + }), + elicitationQuestion({ id: "tags", valueType: "array", maxItems: 2 }), + ], }); assert.deepEqual(acceptResult, { action: "accept", content: { name: "my-app", port: 8080, tags: ["a"] }, }); - // 3. Decline when a required property value violates schema constraint and gets omitted + // 3. Decline when a required property value violates a constraint and gets omitted const declineResult = resolveElicitationResponse({ answers: { name: "my-app", port: "100" }, // 100 < minimum 1024 -> omitted - properties, - requiredKeys: ["port"], + questions: [ + elicitationQuestion({ id: "name", valueType: "string", required: true }), + elicitationQuestion({ + id: "port", + valueType: "integer", + minimum: 1024, + maximum: 65535, + required: true, + }), + ], }); assert.deepEqual(declineResult, { action: "decline" }); // 4. Decline when an array exceeds maxItems on a required field const declineArrayResult = resolveElicitationResponse({ answers: { tags: ["a", "b", "c"] }, // 3 > maxItems 2 -> omitted - properties, - requiredKeys: ["tags"], + questions: [ + elicitationQuestion({ id: "tags", valueType: "array", maxItems: 2, required: true }), + ], }); assert.deepEqual(declineArrayResult, { action: "decline" }); - // 5. Accept when only optional property value is omitted due to constraint violation + // 5. Accept when only an optional property value is omitted due to a constraint violation const acceptOptionalOmitResult = resolveElicitationResponse({ answers: { name: "my-app", port: "100" }, // port violates, but port is optional - properties, - requiredKeys: ["name"], + questions: [ + elicitationQuestion({ id: "name", valueType: "string", required: true }), + elicitationQuestion({ + id: "port", + valueType: "integer", + minimum: 1024, + maximum: 65535, + required: false, + }), + ], }); assert.deepEqual(acceptOptionalOmitResult, { action: "accept", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 114d31664355..273bb10f46db 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -2,6 +2,7 @@ import * as NodePath from "node:path"; import { + normalizeUserInputAnswer, type ChatAttachment, type ModelSelection, type OrchestrationV2ConversationMessage, @@ -1151,6 +1152,10 @@ export function parseElicitationQuestions(params: { }): Array { const requestedSchema = unknownRecord(params.requestedSchema); const properties = unknownRecord(requestedSchema?.properties) ?? {}; + const requiredList = Array.isArray(requestedSchema?.required) + ? requestedSchema.required.filter((item): item is string => typeof item === "string") + : []; + const requiredKeys = new Set(requiredList); return Object.entries(properties).map( ([id, property], index): OrchestrationV2UserInputQuestion => { const record = unknownRecord(property); @@ -1208,6 +1213,30 @@ export function parseElicitationQuestions(params: { isArray && Number.isInteger(record?.maxItems) && (record!.maxItems as number) >= 0 ? (record!.maxItems as number) : undefined; + // Optionless arrays can only receive one typed answer, so clamp their effective item bounds + // to what that answer can satisfy; the UI, validator, and adapter all read the clamped model. + const effectiveMinItems = + minItems !== undefined && options.length === 0 ? Math.min(minItems, 1) : minItems; + const effectiveMaxItems = + maxItems !== undefined && options.length === 0 ? Math.max(maxItems, 1) : maxItems; + const isNumberType = declaredType === "number" || declaredType === "integer"; + const isStringType = declaredType === "string"; + const valueType = + declaredType === "string" || + declaredType === "number" || + declaredType === "integer" || + declaredType === "boolean" || + declaredType === "array" + ? declaredType + : undefined; + const minLength = + isStringType && Number.isInteger(record?.minLength) && (record!.minLength as number) >= 0 + ? (record!.minLength as number) + : undefined; + const maxLength = + isStringType && Number.isInteger(record?.maxLength) && (record!.maxLength as number) >= 0 + ? (record!.maxLength as number) + : undefined; return { id, @@ -1215,100 +1244,51 @@ export function parseElicitationQuestions(params: { question: questionText, options, ...(multiSelect ? { multiSelect: true } : {}), - ...(minItems !== undefined ? { minItems } : {}), - ...(maxItems !== undefined ? { maxItems } : {}), + ...(valueType !== undefined ? { valueType } : {}), + ...(isNumberType && typeof record?.minimum === "number" ? { minimum: record.minimum } : {}), + ...(isNumberType && typeof record?.maximum === "number" ? { maximum: record.maximum } : {}), + ...(isNumberType && typeof record?.exclusiveMinimum === "number" + ? { exclusiveMinimum: record.exclusiveMinimum } + : {}), + ...(isNumberType && typeof record?.exclusiveMaximum === "number" + ? { exclusiveMaximum: record.exclusiveMaximum } + : {}), + ...(minLength !== undefined ? { minLength } : {}), + ...(maxLength !== undefined ? { maxLength } : {}), + ...(isStringType && typeof record?.pattern === "string" && record.pattern.length > 0 + ? { pattern: record.pattern } + : {}), + ...(effectiveMinItems !== undefined ? { minItems: effectiveMinItems } : {}), + ...(effectiveMaxItems !== undefined ? { maxItems: effectiveMaxItems } : {}), ...(allowCustomAnswer !== undefined ? { allowCustomAnswer } : {}), + required: requiredKeys.has(id), }; }, ); } -// Advanced JSON Schema validation keywords (such as exclusiveMinimum, exclusiveMaximum, -// pattern, minLength, and maxLength) are intentionally scoped out here; conversational elicitation -// focuses on core interactive primitives (strings, numbers, booleans, choices, and arrays). +/** + * Coerces answers into ACP elicitation content using the question model. A value that fails + * `normalizeUserInputAnswer` is dropped only as a backstop: dispatch validation rejects those + * submissions while the form is still open, so reaching a drop here means schema drift. + */ export function elicitationContent( answers: ProviderUserInputAnswers, - properties: Record, + questions: ReadonlyArray, ): Record { const content: Record = {}; - for (const [key, rawValue] of Object.entries(answers)) { - if (!Object.prototype.hasOwnProperty.call(properties, key)) continue; - const propSchema = unknownRecord(properties[key]); - const declaredType = typeof propSchema?.type === "string" ? propSchema.type : undefined; - - if (declaredType === "boolean") { - const first = Array.isArray(rawValue) ? rawValue[0] : rawValue; - if (typeof first === "boolean") { - content[key] = first; - } else if (first === "true") { - content[key] = true; - } else if (first === "false") { - content[key] = false; - } - } else if (declaredType === "number" || declaredType === "integer") { - const first = Array.isArray(rawValue) ? rawValue[0] : rawValue; - const num = - typeof first === "number" - ? first - : typeof first === "string" && first.trim().length > 0 - ? Number(first) - : NaN; - if (Number.isFinite(num)) { - if (declaredType === "integer" && !Number.isInteger(num)) { - // Reject floats for integer fields - } else if (typeof propSchema?.minimum === "number" && num < propSchema.minimum) { - // Reject numbers below minimum - } else if (typeof propSchema?.maximum === "number" && num > propSchema.maximum) { - // Reject numbers above maximum - } else { - content[key] = num; - } - } - } else if (declaredType === "array") { - let items: string[] = []; - if (Array.isArray(rawValue)) { - items = rawValue - .filter( - (entry): entry is string | number | boolean => entry !== null && entry !== undefined, - ) - .map(String); - } else if (typeof rawValue === "string" && rawValue.length > 0) { - items = [rawValue]; - } - if (typeof propSchema?.maxItems === "number" && items.length > propSchema.maxItems) { - // Discard answer if it exceeds the allowed maximum item count - } else if (typeof propSchema?.minItems === "number" && items.length < propSchema.minItems) { - // Discard answer if it fails the required minimum item count - } else { - content[key] = items; - } - } else if (declaredType === "string") { - if (typeof rawValue === "string") { - content[key] = rawValue; - } else if (Array.isArray(rawValue) && rawValue.length > 0) { - content[key] = String(rawValue[0]); - } else if (typeof rawValue === "number" || typeof rawValue === "boolean") { - content[key] = String(rawValue); - } - } else { - if (typeof rawValue === "string" || typeof rawValue === "boolean") { - content[key] = rawValue; - } else if (typeof rawValue === "number") { - if (Number.isFinite(rawValue)) { - content[key] = rawValue; - } - } else if (Array.isArray(rawValue)) { - content[key] = rawValue.filter((entry): entry is string => typeof entry === "string"); - } - } + for (const question of questions) { + if (!Object.prototype.hasOwnProperty.call(answers, question.id)) continue; + const normalized = normalizeUserInputAnswer(question, answers[question.id]); + if (!normalized.ok) continue; + content[question.id] = normalized.value; } return content; } export function resolveElicitationResponse(params: { readonly answers: ProviderUserInputAnswers | null; - readonly properties: Record; - readonly requiredKeys?: ReadonlyArray; + readonly questions: ReadonlyArray; }): | { readonly action: "cancel" } | { readonly action: "decline" } @@ -1319,11 +1299,12 @@ export function resolveElicitationResponse(params: { if (params.answers === null) { return { action: "cancel" }; } - const content = elicitationContent(params.answers, params.properties); - const requiredKeys = params.requiredKeys ?? []; - return requiredKeys.some((key) => !Object.prototype.hasOwnProperty.call(content, key)) - ? { action: "decline" } - : { action: "accept", content }; + const content = elicitationContent(params.answers, params.questions); + const missingRequired = params.questions.some( + (question) => + question.required === true && !Object.prototype.hasOwnProperty.call(content, question.id), + ); + return missingRequired ? { action: "decline" } : { action: "accept", content }; } interface ActiveTextSegment { @@ -6034,8 +6015,6 @@ export function makeAcpAdapterV2( // than guessing at their semantics. return { action: "decline" } as const; } - const requestedSchema = unknownRecord(params.requestedSchema); - const properties = unknownRecord(requestedSchema?.properties) ?? {}; const elicitationScopeId = "sessionId" in params ? params.sessionId : `request:${params.requestId}`; const questions = parseElicitationQuestions(params); @@ -6055,15 +6034,9 @@ export function makeAcpAdapterV2( }), transportRequestId, ); - const requiredKeys = Array.isArray(requestedSchema?.required) - ? requestedSchema.required.filter( - (item): item is string => typeof item === "string", - ) - : []; const response = resolveElicitationResponse({ answers: userInput.answers, - properties, - requiredKeys, + questions, }); yield* userInput.acknowledgeNativeResponse; return response; diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index e8431a8ae3f0..01645577f6f2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -622,5 +622,143 @@ it.effect("keeps delegated child pull-request links independent of the parent", const parentAfterChildLink = yield* projections.getThreadProjection(parentThreadId); assert.deepEqual(parentAfterChildLink.thread.linkedPullRequest, parentPullRequest); assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); +it.effect("keeps a live user input request pending when answers violate question constraints", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:elicitation-validation"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-elicitation-validation"), + threadId, + projectId: ProjectId.make("project:elicitation-validation"), + title: "Elicitation", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const sessionId = ProviderSessionId.make("session:elicitation-validation"); + yield* projections.apply({ + id: EventId.make("attach-elicitation-validation"), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: sessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: "gpt-6", + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + const requestId = RuntimeRequestId.make("request:elicitation-validation"); + const nodeId = NodeId.make("node:elicitation-validation"); + yield* projections.apply({ + id: EventId.make("request:elicitation-validation"), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId: sessionId }, + createdAt: now, + resolvedAt: null, + }, + }); + yield* projections.apply({ + id: EventId.make("node:elicitation-validation"), + type: "node.updated", + threadId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* projections.apply({ + id: EventId.make("item:elicitation-validation"), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make("item:elicitation-validation"), + threadId, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "waiting", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "user_input_request", + requestId, + questions: [ + { + id: "port", + header: "Port", + question: "Which port?", + options: [], + valueType: "integer", + minimum: 1, + maximum: 10, + required: true, + }, + ], + }, + }); + + const rejected = yield* orchestrator + .dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make("respond-elicitation-invalid"), + threadId, + requestId, + answers: { port: "99" }, + }) + .pipe(Effect.flip); + assert.include(String(rejected.cause), "Port"); + assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "pending"); + + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make("respond-elicitation-valid"), + threadId, + requestId, + answers: { port: "5" }, + }); + assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "resolved"); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e0a557b414a8..1687da6140bf 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -44,6 +44,7 @@ import { type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, orchestrationV2RunWorkStartedAt, + validateUserInputAnswers, ProviderInstanceId, type ProviderSessionId, RunId, @@ -6894,6 +6895,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Provider session ${providerSessionId} was not found.`, }); } + // Live user input answers are rejected before anything commits, so the request stays + // pending and the form stays open for correction. Message-mode answers keep their own + // string validation below. + if ( + command.answers !== undefined && + runtimeRequest.responseCapability.type === "live" && + context.item?.type === "user_input_request" + ) { + const validation = validateUserInputAnswers(context.item.questions, command.answers); + if (!validation.ok) { + const failedQuestion = context.item.questions.find( + (question) => question.id === validation.questionId, + ); + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + failedQuestion === undefined + ? validation.message + : `${failedQuestion.header}: ${validation.message}`, + }); + } + } const now = yield* DateTime.now; const resolvedRequest = { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8690bb1b2390..07d99aa51006 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -35,6 +35,7 @@ export * from "./pullRequest.ts"; export * from "./orchestrationDispatch.ts"; export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.ts"; +export * from "./userInputValidation.ts"; export * from "./applicationEvent.ts"; export * from "./orchestratorMcp.ts"; export * from "./threadMetadataMcp.ts"; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 4d2ab1a63b4e..883ea1a445ca 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1108,6 +1108,14 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ }), ), multiSelect: Schema.optional(Schema.Boolean), + valueType: Schema.optional(Schema.Literals(["string", "number", "integer", "boolean", "array"])), + minimum: Schema.optional(Schema.Number), + maximum: Schema.optional(Schema.Number), + exclusiveMinimum: Schema.optional(Schema.Number), + exclusiveMaximum: Schema.optional(Schema.Number), + minLength: Schema.optional(NonNegativeInt), + maxLength: Schema.optional(NonNegativeInt), + pattern: Schema.optional(Schema.String), minItems: Schema.optional(NonNegativeInt), maxItems: Schema.optional(NonNegativeInt), allowCustomAnswer: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 73866dfe3060..a8b8fc66ac5c 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -496,6 +496,15 @@ export const UserInputQuestion = Schema.Struct({ ), minItems: Schema.optional(NonNegativeInt), maxItems: Schema.optional(NonNegativeInt), + valueType: Schema.optional(Schema.Literals(["string", "number", "integer", "boolean", "array"])), + minimum: Schema.optional(Schema.Number), + maximum: Schema.optional(Schema.Number), + exclusiveMinimum: Schema.optional(Schema.Number), + exclusiveMaximum: Schema.optional(Schema.Number), + minLength: Schema.optional(NonNegativeInt), + maxLength: Schema.optional(NonNegativeInt), + pattern: Schema.optional(Schema.String), + required: Schema.optional(Schema.Boolean), }); export type UserInputQuestion = typeof UserInputQuestion.Type; diff --git a/packages/contracts/src/userInputValidation.test.ts b/packages/contracts/src/userInputValidation.test.ts new file mode 100644 index 000000000000..c0e1e63276ab --- /dev/null +++ b/packages/contracts/src/userInputValidation.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + normalizeUserInputAnswer, + validateUserInputAnswers, + type UserInputValidationQuestion, +} from "./userInputValidation.ts"; + +const question = ( + overrides: Partial = {}, +): UserInputValidationQuestion => ({ id: "field", ...overrides }); + +describe("validateUserInputAnswers", () => { + it("treats missing answers as errors unless the question is optional", () => { + expect(validateUserInputAnswers([question()], {})).toEqual({ + ok: false, + questionId: "field", + message: "Answer this question.", + }); + expect(validateUserInputAnswers([question({ required: false })], {})).toEqual({ ok: true }); + expect(validateUserInputAnswers([question()], { field: null })).toEqual({ + ok: false, + questionId: "field", + message: "Answer this question.", + }); + }); + + it("accepts answers that satisfy every constraint", () => { + expect( + validateUserInputAnswers( + [ + question({ valueType: "integer", minimum: 1, maximum: 10 }), + question({ id: "name", valueType: "string", minLength: 2 }), + ], + { field: "5", name: "ok" }, + ), + ).toEqual({ ok: true }); + }); + + it("rejects constraint violations with the failing question id", () => { + expect( + validateUserInputAnswers([question({ valueType: "number", maximum: 50 })], { + field: "100", + }), + ).toEqual({ ok: false, questionId: "field", message: "Enter a value of at most 50." }); + expect( + validateUserInputAnswers([question({ valueType: "array", minItems: 2 })], { + field: ["one"], + }), + ).toEqual({ ok: false, questionId: "field", message: "Select at least 2 options." }); + }); +}); + +describe("normalizeUserInputAnswer", () => { + it("parses numbers and enforces integer, inclusive, and exclusive bounds", () => { + expect(normalizeUserInputAnswer(question({ valueType: "integer" }), "8080")).toEqual({ + ok: true, + value: 8080, + }); + expect(normalizeUserInputAnswer(question({ valueType: "integer" }), "3.5")).toEqual({ + ok: false, + message: "Enter a whole number.", + }); + expect(normalizeUserInputAnswer(question({ valueType: "number", minimum: 5 }), "4")).toEqual({ + ok: false, + message: "Enter a value of at least 5.", + }); + expect( + normalizeUserInputAnswer(question({ valueType: "number", exclusiveMinimum: 0 }), "0"), + ).toEqual({ ok: false, message: "Enter a value greater than 0." }); + expect( + normalizeUserInputAnswer(question({ valueType: "number", exclusiveMaximum: 10 }), "10"), + ).toEqual({ ok: false, message: "Enter a value less than 10." }); + expect(normalizeUserInputAnswer(question({ valueType: "number" }), "NaN")).toEqual({ + ok: false, + message: "Enter a number.", + }); + }); + + it("coerces booleans and rejects other values", () => { + expect(normalizeUserInputAnswer(question({ valueType: "boolean" }), "true")).toEqual({ + ok: true, + value: true, + }); + expect(normalizeUserInputAnswer(question({ valueType: "boolean" }), ["false"])).toEqual({ + ok: true, + value: false, + }); + expect(normalizeUserInputAnswer(question({ valueType: "boolean" }), "yes")).toEqual({ + ok: false, + message: "Choose Yes or No.", + }); + }); + + it("keeps empty strings as answers and enforces string constraints", () => { + expect(normalizeUserInputAnswer(question({ valueType: "string" }), "")).toEqual({ + ok: true, + value: "", + }); + expect(normalizeUserInputAnswer(question({ valueType: "string", minLength: 3 }), "ab")).toEqual( + { ok: false, message: "Enter at least 3 characters." }, + ); + expect( + normalizeUserInputAnswer(question({ valueType: "string", maxLength: 2 }), "abc"), + ).toEqual({ ok: false, message: "Enter at most 2 characters." }); + expect( + normalizeUserInputAnswer(question({ valueType: "string", pattern: "^[a-z]+$" }), "Nope"), + ).toEqual({ ok: false, message: "Enter a value in the expected format." }); + }); + + it("ignores malformed constraints instead of guessing", () => { + expect( + normalizeUserInputAnswer(question({ valueType: "string", pattern: "([" }), "anything"), + ).toEqual({ ok: true, value: "anything" }); + expect( + normalizeUserInputAnswer( + question({ valueType: "number", minimum: "10" as unknown as number }), + "5", + ), + ).toEqual({ ok: true, value: 5 }); + }); + + it("normalizes array answers and enforces item counts", () => { + expect(normalizeUserInputAnswer(question({ valueType: "array" }), "single")).toEqual({ + ok: true, + value: ["single"], + }); + expect(normalizeUserInputAnswer(question({ valueType: "array" }), ["a", "b"])).toEqual({ + ok: true, + value: ["a", "b"], + }); + expect( + normalizeUserInputAnswer(question({ valueType: "array", maxItems: 1 }), ["a", "b"]), + ).toEqual({ ok: false, message: "Select at most 1 option." }); + }); + + it("falls back gracefully for untyped questions", () => { + expect(normalizeUserInputAnswer(question(), "value")).toEqual({ ok: true, value: "value" }); + expect(normalizeUserInputAnswer(question(), ["first", "second"])).toEqual({ + ok: true, + value: ["first", "second"], + }); + expect(normalizeUserInputAnswer(question(), { nope: true })).toEqual({ + ok: false, + message: "Enter a valid value.", + }); + }); +}); diff --git a/packages/contracts/src/userInputValidation.ts b/packages/contracts/src/userInputValidation.ts new file mode 100644 index 000000000000..053bac85b628 --- /dev/null +++ b/packages/contracts/src/userInputValidation.ts @@ -0,0 +1,203 @@ +/** + * Shared validation for user input questions. + * + * Constraint fields travel on the question model so the server can reject an answer at dispatch + * time while the form is still open for correction, and so clients can validate before submitting. + * `normalizeUserInputAnswer` is also the adapter's final coercion step; a value that fails here is + * dropped there only as a backstop. + * + * A missing answer means the key is absent, `undefined`, or `null`. An empty string is *not* + * missing: clients send it when attachments carry the answer, and the elicitation content keeps it. + * Malformed provider constraints (non-numeric bounds, invalid regular expressions) are ignored + * rather than guessed at. + */ + +export interface UserInputValidationQuestion { + readonly id: string; + readonly required?: boolean | undefined; + readonly valueType?: "string" | "number" | "integer" | "boolean" | "array" | undefined; + readonly minimum?: number | undefined; + readonly maximum?: number | undefined; + readonly exclusiveMinimum?: number | undefined; + readonly exclusiveMaximum?: number | undefined; + readonly minLength?: number | undefined; + readonly maxLength?: number | undefined; + readonly pattern?: string | undefined; + readonly minItems?: number | undefined; + readonly maxItems?: number | undefined; +} + +export type UserInputAnswerValue = string | number | boolean | ReadonlyArray; + +export type UserInputAnswerNormalization = + | { readonly ok: true; readonly value: UserInputAnswerValue } + | { readonly ok: false; readonly message: string }; + +export type UserInputAnswersValidation = + | { readonly ok: true } + | { readonly ok: false; readonly questionId: string; readonly message: string }; + +export function isMissingUserInputAnswer(rawAnswer: unknown): boolean { + return rawAnswer === undefined || rawAnswer === null; +} + +function normalizeBooleanAnswer(rawAnswer: unknown): UserInputAnswerNormalization { + const first = Array.isArray(rawAnswer) ? rawAnswer[0] : rawAnswer; + if (typeof first === "boolean") return { ok: true, value: first }; + if (first === "true") return { ok: true, value: true }; + if (first === "false") return { ok: true, value: false }; + return { ok: false, message: "Choose Yes or No." }; +} + +function normalizeNumberAnswer( + question: UserInputValidationQuestion, + rawAnswer: unknown, +): UserInputAnswerNormalization { + const first = Array.isArray(rawAnswer) ? rawAnswer[0] : rawAnswer; + const num = + typeof first === "number" + ? first + : typeof first === "string" && first.trim().length > 0 + ? Number(first) + : Number.NaN; + if (!Number.isFinite(num)) return { ok: false, message: "Enter a number." }; + if (question.valueType === "integer" && !Number.isInteger(num)) { + return { ok: false, message: "Enter a whole number." }; + } + if (typeof question.minimum === "number" && num < question.minimum) { + return { ok: false, message: `Enter a value of at least ${question.minimum}.` }; + } + if (typeof question.maximum === "number" && num > question.maximum) { + return { ok: false, message: `Enter a value of at most ${question.maximum}.` }; + } + if (typeof question.exclusiveMinimum === "number" && num <= question.exclusiveMinimum) { + return { ok: false, message: `Enter a value greater than ${question.exclusiveMinimum}.` }; + } + if (typeof question.exclusiveMaximum === "number" && num >= question.exclusiveMaximum) { + return { ok: false, message: `Enter a value less than ${question.exclusiveMaximum}.` }; + } + return { ok: true, value: num }; +} + +function normalizeStringAnswer( + question: UserInputValidationQuestion, + rawAnswer: unknown, +): UserInputAnswerNormalization { + let value: string; + if (typeof rawAnswer === "string") { + value = rawAnswer; + } else if (typeof rawAnswer === "number" || typeof rawAnswer === "boolean") { + value = String(rawAnswer); + } else if (Array.isArray(rawAnswer) && rawAnswer.length > 0) { + value = String(rawAnswer[0]); + } else { + return { ok: false, message: "Enter a value." }; + } + if (typeof question.minLength === "number" && value.length < question.minLength) { + return { ok: false, message: `Enter at least ${question.minLength} characters.` }; + } + if (typeof question.maxLength === "number" && value.length > question.maxLength) { + return { ok: false, message: `Enter at most ${question.maxLength} characters.` }; + } + if (typeof question.pattern === "string" && question.pattern.length > 0) { + try { + if (!new RegExp(question.pattern).test(value)) { + return { ok: false, message: "Enter a value in the expected format." }; + } + } catch { + // A malformed provider pattern is ignored rather than guessed at. + } + } + return { ok: true, value }; +} + +function normalizeArrayAnswer( + question: UserInputValidationQuestion, + rawAnswer: unknown, +): UserInputAnswerNormalization { + let items: string[]; + if (Array.isArray(rawAnswer)) { + items = rawAnswer + .filter((entry): entry is string | number | boolean => entry !== null && entry !== undefined) + .map(String); + } else if (typeof rawAnswer === "string" && rawAnswer.length > 0) { + items = [rawAnswer]; + } else if (typeof rawAnswer === "number" || typeof rawAnswer === "boolean") { + items = [String(rawAnswer)]; + } else { + items = []; + } + if (typeof question.minItems === "number" && items.length < question.minItems) { + return { + ok: false, + message: `Select at least ${question.minItems} option${question.minItems === 1 ? "" : "s"}.`, + }; + } + if (typeof question.maxItems === "number" && items.length > question.maxItems) { + return { + ok: false, + message: `Select at most ${question.maxItems} option${question.maxItems === 1 ? "" : "s"}.`, + }; + } + return { ok: true, value: items }; +} + +function normalizeUntypedAnswer(rawAnswer: unknown): UserInputAnswerNormalization { + if (typeof rawAnswer === "string" || typeof rawAnswer === "boolean") { + return { ok: true, value: rawAnswer }; + } + if (typeof rawAnswer === "number") { + return Number.isFinite(rawAnswer) + ? { ok: true, value: rawAnswer } + : { ok: false, message: "Enter a valid value." }; + } + if (Array.isArray(rawAnswer)) { + return { + ok: true, + value: rawAnswer.filter((entry): entry is string => typeof entry === "string"), + }; + } + return { ok: false, message: "Enter a valid value." }; +} + +export function normalizeUserInputAnswer( + question: UserInputValidationQuestion, + rawAnswer: unknown, +): UserInputAnswerNormalization { + if (isMissingUserInputAnswer(rawAnswer)) { + return { ok: false, message: "Answer this question." }; + } + switch (question.valueType) { + case "boolean": + return normalizeBooleanAnswer(rawAnswer); + case "integer": + case "number": + return normalizeNumberAnswer(question, rawAnswer); + case "array": + return normalizeArrayAnswer(question, rawAnswer); + case "string": + return normalizeStringAnswer(question, rawAnswer); + default: + return normalizeUntypedAnswer(rawAnswer); + } +} + +export function validateUserInputAnswers( + questions: ReadonlyArray, + answers: Readonly>, +): UserInputAnswersValidation { + for (const question of questions) { + const rawAnswer = Object.prototype.hasOwnProperty.call(answers, question.id) + ? answers[question.id] + : undefined; + if (isMissingUserInputAnswer(rawAnswer)) { + if (question.required === false) continue; + return { ok: false, questionId: question.id, message: "Answer this question." }; + } + const normalized = normalizeUserInputAnswer(question, rawAnswer); + if (!normalized.ok) { + return { ok: false, questionId: question.id, message: normalized.message }; + } + } + return { ok: true }; +} From 03a5e03b9e59b3d5efe48e1957e44a5f98cab70d Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:01:19 +0530 Subject: [PATCH 06/15] fix(web,mobile): skip optional questions and pre-validate live answers - builders omit unanswered optional questions instead of blocking the form - web progress can advance past optional questions, and ChatView validates live answers with the shared validator before dispatching, surfacing the message through the existing thread error - tests: optional-skip builder and progress coverage for web and mobile Co-authored-by: CommandCodeBot --- apps/mobile/src/lib/threadActivity.test.ts | 8 ++++++++ apps/mobile/src/lib/threadActivity.ts | 1 + apps/web/src/components/ChatView.tsx | 8 ++++++++ apps/web/src/pendingUserInput.test.ts | 18 ++++++++++++++++++ apps/web/src/pendingUserInput.ts | 3 ++- 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 13dbbec76dab..33d0ed9e5efb 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2388,3 +2388,11 @@ it.each(["provider_error", "usage_limit"] as const)( }); }, ); + +describe("optional pending user input answers", () => { + it("skips unanswered optional questions when building answers", () => { + expect( + buildPendingUserInputAnswers([{ ...singleSelectQuestion, required: false }], {}), + ).toEqual({}); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 7e2eaf0eb300..9186c74c4f0f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1645,6 +1645,7 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { + if (question.required === false) continue; return null; } answers[question.id] = answer; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d02a1ea4c881..5e852bae7739 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,6 +76,7 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, + validateUserInputAnswers, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; @@ -9716,6 +9717,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadId) return; const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; + if (pendingInput.responseCapability === "live") { + const validation = validateUserInputAnswers(pendingInput.questions, answers); + if (!validation.ok) { + setThreadError(activeThreadId, validation.message); + return; + } + } const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); if (userInputResponsesInFlight.current.has(responseKey)) return; const attachmentsByQuestionId = new Map< diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index dec55379d687..d39f1258eacf 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -378,3 +378,21 @@ describe("carryDisplacedCustomAnswerIntoPrompt", () => { ); }); }); + +describe("optional pending user input", () => { + it("skips unanswered optional questions when building answers", () => { + const optional = { ...singleSelectQuestion, required: false }; + expect(buildPendingUserInputAnswers([optional], {})).toEqual({}); + expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull(); + expect( + buildPendingUserInputAnswers([optional, { ...multiSelectQuestion, required: false }], {}), + ).toEqual({}); + }); + + it("allows advancing past an unanswered optional question", () => { + const optional = { ...singleSelectQuestion, required: false }; + const progress = derivePendingUserInputProgress([optional], {}, 0); + expect(progress.canAdvance).toBe(true); + expect(progress.isComplete).toBe(true); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 1e2f9c869a92..aef64c382e8b 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -149,6 +149,7 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { + if (question.required === false) continue; return null; } answers[question.id] = answer; @@ -208,6 +209,6 @@ export function derivePendingUserInputProgress( answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, - canAdvance: resolvedAnswer !== null, + canAdvance: resolvedAnswer !== null || activeQuestion?.required === false, }; } From 74f586358ef28373285daa93646906e80acf5ba7 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:05:05 +0530 Subject: [PATCH 07/15] fix(mobile): surface elicitation validation errors and pick numeric keyboards - validate live answers with the shared validator before responding and show the failure through the existing Alert path instead of a silent no-op - use number/decimal keyboards for integer and number questions on the custom answer input Co-authored-by: CommandCodeBot --- .../src/features/threads/QuestionAttachments.tsx | 7 +++++++ .../src/state/use-selected-thread-requests.ts | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 0214b2440fd8..195e42aa55e7 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -193,6 +193,13 @@ export function QuestionAttachments(props: { onChangeText={props.onChangeText} onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} + keyboardType={ + props.question.valueType === "integer" + ? "number-pad" + : props.question.valueType === "number" + ? "decimal-pad" + : "default" + } placeholder="Or type a custom answer" className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index b33a0d7e4326..a0ded7bfb77e 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -14,7 +14,11 @@ import { import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts"; +import { + type ProviderApprovalDecision, + type RuntimeRequestId, + validateUserInputAnswers, +} from "@t3tools/contracts"; import { type PendingThreadRequests, type ThreadUserInputQuestion, @@ -245,6 +249,16 @@ export function useSelectedThreadRequests() { ) { return; } + if (activePendingUserInput.responseCapability === "live") { + const validation = validateUserInputAnswers( + activePendingUserInput.questions, + activePendingUserInputAnswers, + ); + if (!validation.ok) { + Alert.alert("Check your answer", validation.message); + return; + } + } const responseKey = questionAttachmentDraftKey( selectedThreadShell.environmentId, From 635a49393b34a6dd01f47927c80bf7f3d263feed Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:13:43 +0530 Subject: [PATCH 08/15] refactor(web): share pre-submit answer validation helper - pendingUserInputValidationError wraps the shared validator so the composer and its tests exercise one path - ChatView calls the helper before dispatching live answers - add accept and constraint-rejection unit tests Co-authored-by: CommandCodeBot --- apps/web/src/components/ChatView.tsx | 8 ++++---- apps/web/src/pendingUserInput.test.ts | 22 ++++++++++++++++++++++ apps/web/src/pendingUserInput.ts | 14 +++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5e852bae7739..f91c7fa90681 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,7 +76,6 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, - validateUserInputAnswers, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; @@ -205,6 +204,7 @@ import { buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, + pendingUserInputValidationError, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, @@ -9718,9 +9718,9 @@ export default function ChatView(props: ChatViewProps) { const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; if (pendingInput.responseCapability === "live") { - const validation = validateUserInputAnswers(pendingInput.questions, answers); - if (!validation.ok) { - setThreadError(activeThreadId, validation.message); + const validationError = pendingUserInputValidationError(pendingInput.questions, answers); + if (validationError !== null) { + setThreadError(activeThreadId, validationError); return; } } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index d39f1258eacf..53ddf2002a71 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -6,6 +6,7 @@ import { countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, findFirstUnansweredPendingUserInputQuestionIndex, + pendingUserInputValidationError, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -396,3 +397,24 @@ describe("optional pending user input", () => { expect(progress.isComplete).toBe(true); }); }); + +describe("pendingUserInputValidationError", () => { + it("returns null for answers that satisfy the question constraints", () => { + expect( + pendingUserInputValidationError([singleSelectQuestion], { + scope: "Orchestration-first", + }), + ).toBeNull(); + }); + + it("returns the constraint message for invalid answers", () => { + const bounded = { + ...multiSelectQuestion, + valueType: "array" as const, + minItems: 2, + }; + expect(pendingUserInputValidationError([bounded], { areas: ["Server"] })).toBe( + "Select at least 2 options.", + ); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index aef64c382e8b..95f1739087da 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,4 +1,4 @@ -import type { UserInputQuestion } from "@t3tools/contracts"; +import { validateUserInputAnswers, type UserInputQuestion } from "@t3tools/contracts"; export interface PendingUserInputDraftAnswer { selectedOptionValues?: string[]; @@ -158,6 +158,18 @@ export function buildPendingUserInputAnswers( return answers; } +/** + * Pre-submit check for live answers: returns the first constraint failure, or null when the + * answers can be dispatched. Kept here so the composer and its tests share one path. + */ +export function pendingUserInputValidationError( + questions: ReadonlyArray, + answers: Readonly>, +): string | null { + const validation = validateUserInputAnswers(questions, answers); + return validation.ok ? null : validation.message; +} + export function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray, draftAnswers: Record, From bd6f7507b28aa2c0d5492ff6b1863d9819cbc980 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:16:55 +0530 Subject: [PATCH 09/15] refactor(contracts): keep the missing-answer helper internal Not part of the shared validation surface; avoids an unused export. Co-authored-by: CommandCodeBot --- packages/contracts/src/userInputValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/userInputValidation.ts b/packages/contracts/src/userInputValidation.ts index 053bac85b628..4ee5a54722c6 100644 --- a/packages/contracts/src/userInputValidation.ts +++ b/packages/contracts/src/userInputValidation.ts @@ -37,7 +37,7 @@ export type UserInputAnswersValidation = | { readonly ok: true } | { readonly ok: false; readonly questionId: string; readonly message: string }; -export function isMissingUserInputAnswer(rawAnswer: unknown): boolean { +function isMissingUserInputAnswer(rawAnswer: unknown): boolean { return rawAnswer === undefined || rawAnswer === null; } From 4cb9ed3e5289bd130673ca0a369090f6c0c65f0e Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:32:15 +0530 Subject: [PATCH 10/15] test(server): repair control-reads test structure after rebase resolution The resolution keeps both the upstream delegated-PR-link test and the elicitation dispatch validation test; this restores the block close between them. Co-authored-by: CommandCodeBot --- .../src/orchestration-v2/Orchestrator.control-reads.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 01645577f6f2..994d005d4b3a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -622,6 +622,9 @@ it.effect("keeps delegated child pull-request links independent of the parent", const parentAfterChildLink = yield* projections.getThreadProjection(parentThreadId); assert.deepEqual(parentAfterChildLink.thread.linkedPullRequest, parentPullRequest); assert.deepEqual(parentAfterChildLink.thread.pullRequests, parent.thread.pullRequests); + }).pipe(Effect.provide(testLayer)), +); + it.effect("keeps a live user input request pending when answers violate question constraints", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From a1abe8bbb60d92a78741dbdcf67c41f9b896bcda Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:22:34 +0530 Subject: [PATCH 11/15] fix(acp): validate elicitation domains and risky patterns - Enforce fixed option lists and filter malformed array answers - Skip unsafe provider regexes and validate omitted live form answers --- .../features/threads/QuestionAttachments.tsx | 9 +- .../threads/pendingUserInputLayout.test.ts | 27 ++- .../threads/pendingUserInputLayout.ts | 20 ++ apps/mobile/src/lib/threadActivity.test.ts | 1 + .../Adapters/AcpAdapterV2.test.ts | 69 ++++++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 20 +- .../Orchestrator.control-reads.test.ts | 185 +++++++++------- .../src/orchestration-v2/Orchestrator.ts | 12 +- apps/web/src/pendingUserInput.test.ts | 10 +- .../contracts/src/userInputValidation.test.ts | 107 ++++++++++ packages/contracts/src/userInputValidation.ts | 199 ++++++++++++++++-- 11 files changed, 543 insertions(+), 116 deletions(-) diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 195e42aa55e7..e07530841686 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -12,6 +12,7 @@ import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useNavigation } from "@react-navigation/native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { pendingUserInputKeyboardType } from "./pendingUserInputLayout"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { useServerConfigs } from "../../state/entities"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -193,13 +194,7 @@ export function QuestionAttachments(props: { onChangeText={props.onChangeText} onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} - keyboardType={ - props.question.valueType === "integer" - ? "number-pad" - : props.question.valueType === "number" - ? "decimal-pad" - : "default" - } + keyboardType={pendingUserInputKeyboardType(props.question)} placeholder="Or type a custom answer" className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts index 8dd15ccc8d87..bea0606d322b 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from "vite-plus/test"; -import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout"; +import { + derivePendingUserInputMaxHeight, + pendingUserInputKeyboardType, +} from "./pendingUserInputLayout"; + +describe("pendingUserInputKeyboardType", () => { + it("keeps the number-pad for nonnegative integer questions", () => { + expect(pendingUserInputKeyboardType({ valueType: "integer", minimum: 0 })).toBe("number-pad"); + expect(pendingUserInputKeyboardType({ valueType: "integer", minimum: 1024 })).toBe( + "number-pad", + ); + }); + + it("uses a punctuation layout when integers can be negative", () => { + expect(pendingUserInputKeyboardType({ valueType: "integer" })).toBe("numbers-and-punctuation"); + expect(pendingUserInputKeyboardType({ valueType: "integer", minimum: -10 })).toBe( + "numbers-and-punctuation", + ); + }); + + it("preserves decimal-pad for numbers and default for everything else", () => { + expect(pendingUserInputKeyboardType({ valueType: "number" })).toBe("decimal-pad"); + expect(pendingUserInputKeyboardType({ valueType: "string" })).toBe("default"); + expect(pendingUserInputKeyboardType({})).toBe("default"); + }); +}); describe("derivePendingUserInputMaxHeight", () => { it("caps a tall portrait viewport", () => { diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts index 56924617e56f..c69d5ab26f63 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -35,3 +35,23 @@ export function derivePendingUserInputMaxHeight(input: { Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight), ); } + +/** + * The iOS number-pad has no minus key, so negative-capable integer questions get the + * numbers-and-punctuation layout; Android resolves unknown layouts to the default keyboard, + * which still offers a minus sign. + */ +export function pendingUserInputKeyboardType(question: { + readonly valueType?: "string" | "number" | "integer" | "boolean" | "array" | undefined; + readonly minimum?: number | undefined; +}): "default" | "number-pad" | "decimal-pad" | "numbers-and-punctuation" { + if (question.valueType === "integer") { + return question.minimum !== undefined && question.minimum >= 0 + ? "number-pad" + : "numbers-and-punctuation"; + } + if (question.valueType === "number") { + return "decimal-pad"; + } + return "default"; +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 33d0ed9e5efb..d671b4562cb9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1938,6 +1938,7 @@ const multiSelectQuestion = { options: [ { label: "Orders", description: "Receipts" }, { label: "Listings", description: "Inventory" }, + { label: "Customers", description: "Accounts" }, ], multiSelect: true, } as const; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index e6401c5509d1..3b5e126681de 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -325,6 +325,75 @@ describe("ACP elicitation question parsing and answer serialization", () => { ]); }); + it("narrows boolean options to the declared enum domain", () => { + const questions = parseElicitationQuestions({ + message: "Confirm", + requestedSchema: { + type: "object", + properties: { + mustAccept: { type: "boolean", enum: [true], title: "Accept terms" }, + both: { type: "boolean" }, + emptyEnum: { type: "boolean", enum: ["yes"] }, + }, + }, + }); + + assert.deepEqual(questions[0]?.options, [{ label: "true", description: "Yes", value: "true" }]); + assert.deepEqual(questions[1]?.options, [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ]); + // An enum without boolean entries cannot be answered, so both choices stay available. + assert.deepEqual(questions[2]?.options, [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ]); + + const content = elicitationContent({ mustAccept: "false" }, questions); + assert.isFalse("mustAccept" in content); + assert.deepEqual(resolveElicitationResponse({ answers: { mustAccept: "false" }, questions }), { + action: "accept", + content: {}, + }); + }); + + it("rejects answers outside a fixed option list during serialization", () => { + const questions = parseElicitationQuestions({ + message: "Pick", + requestedSchema: { + type: "object", + required: ["target"], + properties: { + target: { + type: "string", + oneOf: [ + { const: "us-east-1", title: "US East" }, + { const: "eu-west-1", title: "EU West" }, + ], + }, + tags: { + type: "array", + items: { type: "string", enum: ["a", "b"] }, + }, + }, + }, + }); + + // An out-of-domain scalar on a required question declines rather than sending it. + assert.deepEqual(resolveElicitationResponse({ answers: { target: "mars" }, questions }), { + action: "decline", + }); + // Out-of-domain multi-select entries are rejected per item. + assert.deepEqual(elicitationContent({ target: "us-east-1", tags: ["a", "mars"] }, questions), { + target: "us-east-1", + }); + // Declared values pass through untouched. + assert.deepEqual( + resolveElicitationResponse({ answers: { target: "eu-west-1", tags: ["a", "b"] }, questions }), + { action: "accept", content: { target: "eu-west-1", tags: ["a", "b"] } }, + ); + }); + it("skips empty choice options and does not emit blank values or labels while preserving nonblank whitespace", () => { const questions = parseElicitationQuestions({ message: "Select option", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 273bb10f46db..cbc009ec2f1d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1175,10 +1175,22 @@ export function parseElicitationQuestions(params: { options = parseEnumOptions(itemsRecord.enum); } } else if (record?.type === "boolean") { - options = [ - { label: "true", description: "Yes", value: "true" }, - { label: "false", description: "No", value: "false" }, - ]; + // An enum narrows the boolean domain (e.g. only `true` is acceptable); without one + // both choices are offered. Non-boolean enum entries cannot be answered, so they + // are dropped, and an enum with no booleans at all falls back to both choices. + const enumBooleans = Array.isArray(record?.enum) + ? [ + ...new Set( + record.enum.filter((entry): entry is boolean => typeof entry === "boolean"), + ), + ] + : []; + const allowed = enumBooleans.length > 0 ? enumBooleans : [true, false]; + options = allowed.map((entry) => ({ + label: String(entry), + description: entry ? "Yes" : "No", + value: String(entry), + })); } else { const choiceList = record?.oneOf ?? record?.anyOf; if (Array.isArray(choiceList)) { diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 994d005d4b3a..534f5a17fcd3 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -664,84 +664,90 @@ it.effect("keeps a live user input request pending when answers violate question lastError: null, }, }); - const requestId = RuntimeRequestId.make("request:elicitation-validation"); - const nodeId = NodeId.make("node:elicitation-validation"); - yield* projections.apply({ - id: EventId.make("request:elicitation-validation"), - type: "runtime-request.updated", - threadId, - occurredAt: now, - payload: { - id: requestId, - nodeId, - providerTurnId: null, - nativeRequestRef: null, - kind: "user_input", - status: "pending", - responseCapability: { type: "live", providerSessionId: sessionId }, - createdAt: now, - resolvedAt: null, - }, - }); - yield* projections.apply({ - id: EventId.make("node:elicitation-validation"), - type: "node.updated", - threadId, - occurredAt: now, - payload: { - id: nodeId, - threadId, - runId: null, - parentNodeId: null, - rootNodeId: nodeId, - kind: "user_input_request", - status: "waiting", - countsForRun: false, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }, - }); - yield* projections.apply({ - id: EventId.make("item:elicitation-validation"), - type: "turn-item.updated", - threadId, - occurredAt: now, - payload: { - id: TurnItemId.make("item:elicitation-validation"), - threadId, - runId: null, - nodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "waiting", - title: null, - startedAt: now, - completedAt: null, - updatedAt: now, - type: "user_input_request", - requestId, - questions: [ - { - id: "port", - header: "Port", - question: "Which port?", - options: [], - valueType: "integer", - minimum: 1, - maximum: 10, - required: true, + const registerPendingPortQuestion = (suffix: string) => + Effect.gen(function* () { + const requestId = RuntimeRequestId.make(`request:${suffix}`); + const nodeId = NodeId.make(`node:${suffix}`); + yield* projections.apply({ + id: EventId.make(`request:${suffix}`), + type: "runtime-request.updated", + threadId, + occurredAt: now, + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId: sessionId }, + createdAt: now, + resolvedAt: null, }, - ], - }, - }); + }); + yield* projections.apply({ + id: EventId.make(`node:${suffix}`), + type: "node.updated", + threadId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* projections.apply({ + id: EventId.make(`item:${suffix}`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:${suffix}`), + threadId, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "waiting", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "user_input_request", + requestId, + questions: [ + { + id: "port", + header: "Port", + question: "Which port?", + options: [], + valueType: "integer", + minimum: 1, + maximum: 10, + required: true, + }, + ], + }, + }); + return requestId; + }); + + const requestId = yield* registerPendingPortQuestion("elicitation-validation"); const rejected = yield* orchestrator .dispatch({ @@ -755,6 +761,18 @@ it.effect("keeps a live user input request pending when answers violate question assert.include(String(rejected.cause), "Port"); assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "pending"); + // A form-accepting response that omits answers entirely is validated the same way. + const omitted = yield* orchestrator + .dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make("respond-elicitation-omitted"), + threadId, + requestId, + }) + .pipe(Effect.flip); + assert.include(String(omitted.cause), "Port"); + assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "pending"); + yield* orchestrator.dispatch({ type: "runtime-request.respond", commandId: CommandId.make("respond-elicitation-valid"), @@ -763,5 +781,18 @@ it.effect("keeps a live user input request pending when answers violate question answers: { port: "5" }, }); assert.equal((yield* projections.getRuntimeRequest(threadId, requestId))?.status, "resolved"); + + // Explicit cancellation without answers bypasses validation and resolves as cancelled. + const cancelRequestId = yield* registerPendingPortQuestion("elicitation-cancel"); + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make("respond-elicitation-cancel"), + threadId, + requestId: cancelRequestId, + decision: "cancel", + }); + const cancelContext = yield* projections.getRuntimeResponseContext(threadId, cancelRequestId); + assert.equal(cancelContext.request?.status, "resolved"); + assert.equal(cancelContext.node?.status, "cancelled"); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1687da6140bf..3412e9ad37c6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -6896,14 +6896,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } // Live user input answers are rejected before anything commits, so the request stays - // pending and the form stays open for correction. Message-mode answers keep their own - // string validation below. + // pending and the form stays open for correction. A form-accepting response validates + // even when answers are omitted entirely; explicit cancellation or decline skips it. + // Message-mode answers keep their own string validation below. if ( - command.answers !== undefined && runtimeRequest.responseCapability.type === "live" && - context.item?.type === "user_input_request" + context.item?.type === "user_input_request" && + command.decision !== "cancel" && + command.decision !== "decline" ) { - const validation = validateUserInputAnswers(context.item.questions, command.answers); + const validation = validateUserInputAnswers(context.item.questions, command.answers ?? {}); if (!validation.ok) { const failedQuestion = context.item.questions.find( (question) => question.id === validation.questionId, diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 53ddf2002a71..66da5052c45c 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -38,6 +38,14 @@ const multiSelectQuestion = { label: "Web", description: "Web", }, + { + label: "Desktop", + description: "Desktop", + }, + { + label: "Customers", + description: "Customers", + }, ], multiSelect: true, } as const; @@ -159,7 +167,7 @@ describe("togglePendingUserInputOptionSelection", () => { const two = togglePendingUserInputOptionSelection(question, one, "Desktop"); expect(two.selectedOptionValues).toEqual(["Server", "Desktop"]); - const three = togglePendingUserInputOptionSelection(question, two, "Web"); + const three = togglePendingUserInputOptionSelection(question, two, "Customers"); expect(three.selectedOptionValues).toEqual(["Server", "Desktop"]); const deselected = togglePendingUserInputOptionSelection(question, two, "Server"); diff --git a/packages/contracts/src/userInputValidation.test.ts b/packages/contracts/src/userInputValidation.test.ts index c0e1e63276ab..17ec1051ade4 100644 --- a/packages/contracts/src/userInputValidation.test.ts +++ b/packages/contracts/src/userInputValidation.test.ts @@ -145,4 +145,111 @@ describe("normalizeUserInputAnswer", () => { message: "Enter a valid value.", }); }); + + it("stringifies primitive entries in untyped arrays and drops the rest", () => { + expect(normalizeUserInputAnswer(question(), ["a", 1, true])).toEqual({ + ok: true, + value: ["a", "1", "true"], + }); + expect(normalizeUserInputAnswer(question(), ["a", { nope: true }, null, "b"])).toEqual({ + ok: true, + value: ["a", "b"], + }); + }); + + it("rejects answers outside a fixed option list", () => { + const fixed = question({ + allowCustomAnswer: false, + options: [ + { label: "Server", value: "srv" }, + { label: "Web", value: "web" }, + ], + }); + expect(normalizeUserInputAnswer(fixed, "srv")).toEqual({ ok: true, value: "srv" }); + expect(normalizeUserInputAnswer(fixed, "bogus")).toEqual({ + ok: false, + message: "Choose from the provided options.", + }); + expect( + normalizeUserInputAnswer(question({ ...fixed, valueType: "array" }), ["srv", "bogus"]), + ).toEqual({ ok: false, message: "Choose from the provided options." }); + expect( + normalizeUserInputAnswer(question({ ...fixed, valueType: "array" }), ["srv", "web"]), + ).toEqual({ ok: true, value: ["srv", "web"] }); + }); + + it("rejects a boolean answer outside a narrowed boolean domain", () => { + const onlyTrue = question({ + valueType: "boolean", + allowCustomAnswer: false, + options: [{ label: "true", value: "true" }], + }); + expect(normalizeUserInputAnswer(onlyTrue, "true")).toEqual({ ok: true, value: true }); + expect(normalizeUserInputAnswer(onlyTrue, "false")).toEqual({ + ok: false, + message: "Choose from the provided options.", + }); + }); + + it("matches valueless options by label and leaves custom-answer questions open", () => { + const byLabel = question({ + allowCustomAnswer: false, + options: [{ label: "Server" }, { label: "Web" }], + }); + expect(normalizeUserInputAnswer(byLabel, "Web")).toEqual({ ok: true, value: "Web" }); + expect(normalizeUserInputAnswer(byLabel, "Other")).toEqual({ + ok: false, + message: "Choose from the provided options.", + }); + const open = question({ + options: [{ label: "Server" }], + }); + expect(normalizeUserInputAnswer(open, "Anything")).toEqual({ ok: true, value: "Anything" }); + }); + + it("ignores patterns that could backtrack catastrophically", () => { + const dangerous = [ + "^(a+)+$", + "^(a|aa)+$", + "^(\\w+\\s?)*$", + "^((a+)b)*$", + ".*a.*b.*c.*d.*", + "(a)\\1", + "(?<=x)a", + "a{1,99999}", + ]; + for (const pattern of dangerous) { + expect( + normalizeUserInputAnswer(question({ valueType: "string", pattern }), "anything"), + ).toEqual({ + ok: true, + value: "anything", + }); + } + }); + + it("still enforces patterns that pass the safety screen", () => { + expect( + normalizeUserInputAnswer(question({ valueType: "string", pattern: "^[a-z]+$" }), "Nope"), + ).toEqual({ ok: false, message: "Enter a value in the expected format." }); + expect( + normalizeUserInputAnswer( + question({ valueType: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" }), + "1.2.3", + ), + ).toEqual({ ok: true, value: "1.2.3" }); + expect( + normalizeUserInputAnswer( + question({ valueType: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" }), + "1.2.x", + ), + ).toEqual({ ok: false, message: "Enter a value in the expected format." }); + }); + + it("skips pattern checks for answers beyond the length cap", () => { + const longAnswer = "a".repeat(300); + expect( + normalizeUserInputAnswer(question({ valueType: "string", pattern: "^[a-z]+$" }), longAnswer), + ).toEqual({ ok: true, value: longAnswer }); + }); }); diff --git a/packages/contracts/src/userInputValidation.ts b/packages/contracts/src/userInputValidation.ts index 4ee5a54722c6..ebb6e2dfb76d 100644 --- a/packages/contracts/src/userInputValidation.ts +++ b/packages/contracts/src/userInputValidation.ts @@ -10,6 +10,12 @@ * missing: clients send it when attachments carry the answer, and the elicitation content keeps it. * Malformed provider constraints (non-numeric bounds, invalid regular expressions) are ignored * rather than guessed at. + * + * A question with `allowCustomAnswer: false` treats its option list as the declared value + * domain: any answer outside it is rejected. Provider-supplied `pattern`s run on shared event + * loops, so only patterns whose backtracking is provably bounded are executed — anything else + * is ignored like a malformed pattern, and pattern checking is advisory (a skipped pattern + * never blocks submission). */ export interface UserInputValidationQuestion { @@ -25,6 +31,13 @@ export interface UserInputValidationQuestion { readonly pattern?: string | undefined; readonly minItems?: number | undefined; readonly maxItems?: number | undefined; + readonly allowCustomAnswer?: boolean | undefined; + readonly options?: + | ReadonlyArray<{ + readonly label: string; + readonly value?: string | undefined; + }> + | undefined; } export type UserInputAnswerValue = string | number | boolean | ReadonlyArray; @@ -41,6 +54,11 @@ function isMissingUserInputAnswer(rawAnswer: unknown): boolean { return rawAnswer === undefined || rawAnswer === null; } +// Only primitives stringified by clients are accepted into array answers; anything else +// (objects, null) is dropped rather than stringified into "[object Object]". +const isAnswerPrimitive = (entry: unknown): entry is string | number | boolean => + typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean"; + function normalizeBooleanAnswer(rawAnswer: unknown): UserInputAnswerNormalization { const first = Array.isArray(rawAnswer) ? rawAnswer[0] : rawAnswer; if (typeof first === "boolean") return { ok: true, value: first }; @@ -79,6 +97,117 @@ function normalizeNumberAnswer( return { ok: true, value: num }; } +// Provider-supplied patterns execute on shared event loops (the server orchestrator and the +// clients), where a catastrophic-backtracking regex would hang the whole app. Only patterns +// whose backtracking is provably bounded are executed: no lookarounds or backreferences, no +// quantified group containing a quantifier or alternation (the exponential shape, at any +// nesting depth), at most three unbounded quantifiers, and no large bounded repeats. With the +// answer-length cap, a screened match costs at most ~256^3 character comparisons — a few +// milliseconds, never a hang. Patterns that fail the screen are ignored like malformed ones; +// pattern checking is advisory and never blocks submission. +const PATTERN_MAX_LENGTH = 200; +const PATTERN_MAX_ANSWER_LENGTH = 256; +const PATTERN_MAX_UNBOUNDED_QUANTIFIERS = 3; +const PATTERN_MAX_REPEAT = 512; + +function isSafeUserInputPattern(pattern: string): boolean { + if (pattern.length > PATTERN_MAX_LENGTH) return false; + let unboundedQuantifiers = 0; + let lastClosedGroupRisky = false; + let inCharClass = false; + const groupRiskStack: boolean[] = []; + const markCurrentGroupRisky = () => { + if (groupRiskStack.length > 0) { + groupRiskStack[groupRiskStack.length - 1] = true; + } + }; + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]!; + if (inCharClass) { + if (char === "\\") index++; + else if (char === "]") inCharClass = false; + continue; + } + if (char === "\\") { + // Backreferences make matching NP-hard and defeat the bounded-backtracking proof. + const next = pattern[index + 1]; + if (next !== undefined && next >= "1" && next <= "9") return false; + index++; + lastClosedGroupRisky = false; + continue; + } + if (char === "[") { + inCharClass = true; + lastClosedGroupRisky = false; + continue; + } + if (char === "(") { + if (pattern[index + 1] === "?") { + const after = pattern[index + 2]; + if (after === "=" || after === "!") return false; + if (after === "<") { + const behind = pattern[index + 3]; + if (behind === "=" || behind === "!") return false; + } + } + groupRiskStack.push(false); + lastClosedGroupRisky = false; + continue; + } + if (char === ")") { + const risky = groupRiskStack.pop() ?? false; + if (risky) markCurrentGroupRisky(); + lastClosedGroupRisky = risky; + continue; + } + if (char === "|") { + markCurrentGroupRisky(); + lastClosedGroupRisky = false; + continue; + } + const isStarOrPlus = char === "*" || char === "+"; + const isBrace = char === "{"; + if (!isStarOrPlus && !isBrace && char !== "?") { + lastClosedGroupRisky = false; + continue; + } + // Quantifier tokens. Repeating a group that itself contains a quantifier or an + // alternation is the classic exponential shape; `?` cannot repeat, so it is exempt. + if (char !== "?" && lastClosedGroupRisky) return false; + markCurrentGroupRisky(); + lastClosedGroupRisky = false; + if (isStarOrPlus) { + unboundedQuantifiers++; + if (unboundedQuantifiers > PATTERN_MAX_UNBOUNDED_QUANTIFIERS) return false; + continue; + } + if (isBrace) { + const close = pattern.indexOf("}", index); + const bounds = close === -1 ? undefined : pattern.slice(index + 1, close); + const match = bounds?.match(/^(\d+)(?:,(\d*))?$/); + if (bounds !== undefined && match) { + const isOpenEnded = bounds.includes(",") && (match[2] ?? "").length === 0; + if (isOpenEnded) { + unboundedQuantifiers++; + if (unboundedQuantifiers > PATTERN_MAX_UNBOUNDED_QUANTIFIERS) return false; + } + if (Number(match[1]) > PATTERN_MAX_REPEAT) return false; + if ( + match[2] !== undefined && + match[2].length > 0 && + Number(match[2]) > PATTERN_MAX_REPEAT + ) { + return false; + } + index = close; + } + // A `{` that does not parse as a quantifier is a literal brace; the RegExp + // constructor reports genuinely malformed patterns. + } + } + return true; +} + function normalizeStringAnswer( question: UserInputValidationQuestion, rawAnswer: unknown, @@ -99,7 +228,12 @@ function normalizeStringAnswer( if (typeof question.maxLength === "number" && value.length > question.maxLength) { return { ok: false, message: `Enter at most ${question.maxLength} characters.` }; } - if (typeof question.pattern === "string" && question.pattern.length > 0) { + if ( + typeof question.pattern === "string" && + question.pattern.length > 0 && + value.length <= PATTERN_MAX_ANSWER_LENGTH && + isSafeUserInputPattern(question.pattern) + ) { try { if (!new RegExp(question.pattern).test(value)) { return { ok: false, message: "Enter a value in the expected format." }; @@ -117,9 +251,7 @@ function normalizeArrayAnswer( ): UserInputAnswerNormalization { let items: string[]; if (Array.isArray(rawAnswer)) { - items = rawAnswer - .filter((entry): entry is string | number | boolean => entry !== null && entry !== undefined) - .map(String); + items = rawAnswer.filter(isAnswerPrimitive).map(String); } else if (typeof rawAnswer === "string" && rawAnswer.length > 0) { items = [rawAnswer]; } else if (typeof rawAnswer === "number" || typeof rawAnswer === "boolean") { @@ -152,14 +284,35 @@ function normalizeUntypedAnswer(rawAnswer: unknown): UserInputAnswerNormalizatio : { ok: false, message: "Enter a valid value." }; } if (Array.isArray(rawAnswer)) { - return { - ok: true, - value: rawAnswer.filter((entry): entry is string => typeof entry === "string"), - }; + // Untyped arrays are stringified like the typed array path instead of silently + // dropping entries, so a numeric answer list survives the fallback path intact. + return { ok: true, value: rawAnswer.filter(isAnswerPrimitive).map(String) }; } return { ok: false, message: "Enter a valid value." }; } +// A fixed option list (`allowCustomAnswer: false`) is the declared value domain of the +// provider's schema, so an answer outside it is rejected rather than sent to the agent. +// Boolean and numeric options store stringified values, which `String()` reproduces from +// the coerced scalar. +function enforceAllowedOptions( + question: UserInputValidationQuestion, + value: UserInputAnswerValue, +): UserInputAnswerNormalization { + if ( + question.allowCustomAnswer !== false || + question.options === undefined || + question.options.length === 0 + ) { + return { ok: true, value }; + } + const allowed = new Set(question.options.map((option) => option.value ?? option.label)); + const entries = Array.isArray(value) ? value : [String(value)]; + return entries.every((entry) => allowed.has(entry)) + ? { ok: true, value } + : { ok: false, message: "Choose from the provided options." }; +} + export function normalizeUserInputAnswer( question: UserInputValidationQuestion, rawAnswer: unknown, @@ -167,19 +320,23 @@ export function normalizeUserInputAnswer( if (isMissingUserInputAnswer(rawAnswer)) { return { ok: false, message: "Answer this question." }; } - switch (question.valueType) { - case "boolean": - return normalizeBooleanAnswer(rawAnswer); - case "integer": - case "number": - return normalizeNumberAnswer(question, rawAnswer); - case "array": - return normalizeArrayAnswer(question, rawAnswer); - case "string": - return normalizeStringAnswer(question, rawAnswer); - default: - return normalizeUntypedAnswer(rawAnswer); - } + const normalized = ((): UserInputAnswerNormalization => { + switch (question.valueType) { + case "boolean": + return normalizeBooleanAnswer(rawAnswer); + case "integer": + case "number": + return normalizeNumberAnswer(question, rawAnswer); + case "array": + return normalizeArrayAnswer(question, rawAnswer); + case "string": + return normalizeStringAnswer(question, rawAnswer); + default: + return normalizeUntypedAnswer(rawAnswer); + } + })(); + if (!normalized.ok) return normalized; + return enforceAllowedOptions(question, normalized.value); } export function validateUserInputAnswers( From 4501e424e830f91d368f472831a2078da2067c1c Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:12:43 +0530 Subject: [PATCH 12/15] fix(acp): address review comments on numeric keyboards, boolean enums, and array minItems - mobile: switch number questions to numbers-and-punctuation layout unless minimum >= 0 is declared, handling iOS decimal-pad minus key limitation - server: treat boolean schemas with non-empty enums containing no boolean values as contradictory (yielding empty options) and decline elicitation - server: preserve declared minItems on optionless arrays so undersized answers fail validation instead of clamping to 1 --- .../threads/pendingUserInputLayout.test.ts | 11 +++++- .../threads/pendingUserInputLayout.ts | 13 +++---- .../Adapters/AcpAdapterV2.test.ts | 37 ++++++++++++++----- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 28 +++++++++----- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts index bea0606d322b..fa6e9399f25c 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts @@ -20,8 +20,15 @@ describe("pendingUserInputKeyboardType", () => { ); }); - it("preserves decimal-pad for numbers and default for everything else", () => { - expect(pendingUserInputKeyboardType({ valueType: "number" })).toBe("decimal-pad"); + it("keeps the decimal-pad only for nonnegative number questions", () => { + expect(pendingUserInputKeyboardType({ valueType: "number", minimum: 0 })).toBe("decimal-pad"); + expect(pendingUserInputKeyboardType({ valueType: "number" })).toBe("numbers-and-punctuation"); + expect(pendingUserInputKeyboardType({ valueType: "number", minimum: -1.5 })).toBe( + "numbers-and-punctuation", + ); + }); + + it("preserves the default layout for everything else", () => { expect(pendingUserInputKeyboardType({ valueType: "string" })).toBe("default"); expect(pendingUserInputKeyboardType({})).toBe("default"); }); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts index c69d5ab26f63..bd3081fe41bb 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -37,21 +37,20 @@ export function derivePendingUserInputMaxHeight(input: { } /** - * The iOS number-pad has no minus key, so negative-capable integer questions get the - * numbers-and-punctuation layout; Android resolves unknown layouts to the default keyboard, - * which still offers a minus sign. + * The iOS number-pad and decimal-pad have no minus key, so negative-capable numeric questions + * get the numbers-and-punctuation layout; Android resolves unknown layouts to the default + * keyboard, which still offers a minus sign. */ export function pendingUserInputKeyboardType(question: { readonly valueType?: "string" | "number" | "integer" | "boolean" | "array" | undefined; readonly minimum?: number | undefined; }): "default" | "number-pad" | "decimal-pad" | "numbers-and-punctuation" { + const excludesNegatives = question.minimum !== undefined && question.minimum >= 0; if (question.valueType === "integer") { - return question.minimum !== undefined && question.minimum >= 0 - ? "number-pad" - : "numbers-and-punctuation"; + return excludesNegatives ? "number-pad" : "numbers-and-punctuation"; } if (question.valueType === "number") { - return "decimal-pad"; + return excludesNegatives ? "decimal-pad" : "numbers-and-punctuation"; } return "default"; } diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 3b5e126681de..7eab78b61704 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -333,7 +333,6 @@ describe("ACP elicitation question parsing and answer serialization", () => { properties: { mustAccept: { type: "boolean", enum: [true], title: "Accept terms" }, both: { type: "boolean" }, - emptyEnum: { type: "boolean", enum: ["yes"] }, }, }, }); @@ -343,11 +342,6 @@ describe("ACP elicitation question parsing and answer serialization", () => { { label: "true", description: "Yes", value: "true" }, { label: "false", description: "No", value: "false" }, ]); - // An enum without boolean entries cannot be answered, so both choices stay available. - assert.deepEqual(questions[2]?.options, [ - { label: "true", description: "Yes", value: "true" }, - { label: "false", description: "No", value: "false" }, - ]); const content = elicitationContent({ mustAccept: "false" }, questions); assert.isFalse("mustAccept" in content); @@ -357,6 +351,30 @@ describe("ACP elicitation question parsing and answer serialization", () => { }); }); + it("declines elicitation when a boolean enum leaves no answerable option", () => { + const questions = parseElicitationQuestions({ + message: "Confirm", + requestedSchema: { + type: "object", + properties: { + broken: { type: "boolean", enum: ["yes"] }, + name: { type: "string" }, + }, + }, + }); + + // An explicit enum without boolean values contradicts the boolean type: no options. + assert.deepEqual(questions[0]?.options, []); + // The elicitation declines even when every other question is answered. + assert.deepEqual(resolveElicitationResponse({ answers: { name: "app" }, questions }), { + action: "decline", + }); + // Cancellation still takes precedence over the decline. + assert.deepEqual(resolveElicitationResponse({ answers: null, questions }), { + action: "cancel", + }); + }); + it("rejects answers outside a fixed option list during serialization", () => { const questions = parseElicitationQuestions({ message: "Pick", @@ -479,7 +497,7 @@ describe("ACP elicitation question parsing and answer serialization", () => { ]); }); - it("clamps minimum item counts for optionless arrays to what a typed answer can satisfy", () => { + it("keeps the declared minimum for optionless arrays so undersized answers fail validation", () => { const questions = parseElicitationQuestions({ message: "Add tags", requestedSchema: { @@ -490,10 +508,11 @@ describe("ACP elicitation question parsing and answer serialization", () => { }, }); - assert.equal(questions[0]?.minItems, 1); + assert.equal(questions[0]?.minItems, 3); + // The maximum is still raised so a lone typed answer is not rejected outright. assert.equal(questions[0]?.maxItems, 1); const content = elicitationContent({ tags: "solo" }, questions); - assert.deepEqual(content, { tags: ["solo"] }); + assert.deepEqual(content, {}); }); it("coerces answers in elicitationContent based on property schema types and constraints", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index cbc009ec2f1d..e4e9e4ae82c8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1176,17 +1176,17 @@ export function parseElicitationQuestions(params: { } } else if (record?.type === "boolean") { // An enum narrows the boolean domain (e.g. only `true` is acceptable); without one - // both choices are offered. Non-boolean enum entries cannot be answered, so they - // are dropped, and an enum with no booleans at all falls back to both choices. + // both choices are offered. An explicit enum with no boolean values is a + // contradictory schema: the question gets no options, and the response path + // declines the elicitation rather than accepting a value the agent forbade. const enumBooleans = Array.isArray(record?.enum) ? [ ...new Set( record.enum.filter((entry): entry is boolean => typeof entry === "boolean"), ), ] - : []; - const allowed = enumBooleans.length > 0 ? enumBooleans : [true, false]; - options = allowed.map((entry) => ({ + : undefined; + options = (enumBooleans ?? [true, false]).map((entry) => ({ label: String(entry), description: entry ? "Yes" : "No", value: String(entry), @@ -1225,10 +1225,9 @@ export function parseElicitationQuestions(params: { isArray && Number.isInteger(record?.maxItems) && (record!.maxItems as number) >= 0 ? (record!.maxItems as number) : undefined; - // Optionless arrays can only receive one typed answer, so clamp their effective item bounds - // to what that answer can satisfy; the UI, validator, and adapter all read the clamped model. - const effectiveMinItems = - minItems !== undefined && options.length === 0 ? Math.min(minItems, 1) : minItems; + // Optionless arrays can only receive one typed answer. The declared minimum still + // applies so an undersized answer fails validation; the maximum is raised to one so a + // lone typed answer is not rejected outright. const effectiveMaxItems = maxItems !== undefined && options.length === 0 ? Math.max(maxItems, 1) : maxItems; const isNumberType = declaredType === "number" || declaredType === "integer"; @@ -1270,7 +1269,7 @@ export function parseElicitationQuestions(params: { ...(isStringType && typeof record?.pattern === "string" && record.pattern.length > 0 ? { pattern: record.pattern } : {}), - ...(effectiveMinItems !== undefined ? { minItems: effectiveMinItems } : {}), + ...(minItems !== undefined ? { minItems } : {}), ...(effectiveMaxItems !== undefined ? { maxItems: effectiveMaxItems } : {}), ...(allowCustomAnswer !== undefined ? { allowCustomAnswer } : {}), required: requiredKeys.has(id), @@ -1311,6 +1310,15 @@ export function resolveElicitationResponse(params: { if (params.answers === null) { return { action: "cancel" }; } + // A boolean question with no options comes from a contradictory schema (an enum with no + // boolean values); the form cannot be answered faithfully, so decline outright. + if ( + params.questions.some( + (question) => question.valueType === "boolean" && question.options.length === 0, + ) + ) { + return { action: "decline" }; + } const content = elicitationContent(params.answers, params.questions); const missingRequired = params.questions.some( (question) => From 39844a8e94956a0f311fbff8f6fd85cef0bc4ba4 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:14:32 +0530 Subject: [PATCH 13/15] fix(contracts,server,web,mobile): resolve elicitation validation and layout findings --- apps/mobile/src/lib/threadActivity.test.ts | 13 ++ apps/mobile/src/lib/threadActivity.ts | 17 ++- .../Adapters/AcpAdapterV2.test.ts | 124 ++++++++++++++++++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 49 ++++++- apps/web/src/pendingUserInput.test.ts | 27 +++- apps/web/src/pendingUserInput.ts | 17 ++- .../contracts/src/userInputValidation.test.ts | 12 ++ 7 files changed, 233 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d671b4562cb9..e61b75b13777 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1987,10 +1987,23 @@ describe("pending user input answers", () => { const three = togglePendingUserInputOptionSelection(question, two, "Customers"); expect(three.selectedOptionValues).toEqual(["Orders", "Listings"]); + const withCustom = { selectedOptionValues: ["Orders", "Listings"], customAnswer: "keep this" }; + const cappedTap = togglePendingUserInputOptionSelection(question, withCustom, "Customers"); + expect(cappedTap).toBe(withCustom); + expect(cappedTap.selectedOptionValues).toEqual(["Orders", "Listings"]); + expect(cappedTap.customAnswer).toBe("keep this"); + const deselected = togglePendingUserInputOptionSelection(question, two, "Orders"); expect(deselected.selectedOptionValues).toEqual(["Listings"]); }); + it("submits empty array for required multi-select questions with minItems 0", () => { + const zeroMin = { ...multiSelectQuestion, minItems: 0 }; + expect(buildPendingUserInputAnswers([zeroMin], {})).toEqual({ + scope: [], + }); + }); + it("builds array answers for multi-select questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9186c74c4f0f..7099e687b30f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -367,11 +367,13 @@ function resolvePendingUserInputAnswer( const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 - ? "" - : null; + if (selectedOptionValues.length > 0) { + return selectedOptionValues; + } + if (question.minItems === 0) { + return []; + } + return question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null; } return ( selectedOptionValues[0] ?? @@ -1613,10 +1615,7 @@ export function togglePendingUserInputOptionSelection( question.maxItems !== undefined && selectedOptionValues.length >= question.maxItems ) { - return { - customAnswer: "", - ...(selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; + return draft ?? {}; } const nextSelectedOptionValues = isSelected ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 7eab78b61704..e21119562dc9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -653,6 +653,130 @@ describe("ACP elicitation question parsing and answer serialization", () => { assert.isFalse(questions[3]?.required); }); + it("parses anyOf choice options and respects value/label properties", () => { + const questions = parseElicitationQuestions({ + message: "Choose options", + requestedSchema: { + type: "object", + properties: { + runtime: { + title: "Runtime", + anyOf: [ + { value: "node", label: "Node.js", description: "Node engine" }, + { value: "bun", label: "Bun" }, + ], + }, + packages: { + type: "array", + items: { + anyOf: [ + { value: "pkg-a", label: "Package A" }, + { value: "pkg-b", label: "Package B" }, + ], + }, + }, + }, + }, + }); + + assert.deepEqual(questions, [ + { + id: "runtime", + header: "Runtime", + question: "Choose options", + options: [ + { label: "Node.js", description: "Node engine", value: "node" }, + { label: "Bun", description: "Bun", value: "bun" }, + ], + allowCustomAnswer: false, + required: false, + }, + { + id: "packages", + header: "Question 2", + question: "Choose options", + options: [ + { label: "Package A", description: "Package A", value: "pkg-a" }, + { label: "Package B", description: "Package B", value: "pkg-b" }, + ], + valueType: "array", + multiSelect: true, + allowCustomAnswer: false, + required: false, + }, + ]); + }); + + it("infers integer or number valueType for untyped numeric enums and coerces answers", () => { + const questions = parseElicitationQuestions({ + message: "Pick port and rate", + requestedSchema: { + type: "object", + properties: { + port: { + enum: [3000, 8080], + }, + ratio: { + enum: [0.5, 1.5], + }, + }, + }, + }); + + assert.equal(questions[0]?.valueType, "integer"); + assert.equal(questions[1]?.valueType, "number"); + const content = elicitationContent({ port: "8080", ratio: "1.5" }, questions); + assert.deepEqual(content, { + port: 8080, + ratio: 1.5, + }); + }); + + it("generates appropriate question hints for exclusive and mixed numeric bounds", () => { + const questions = parseElicitationQuestions({ + message: "Number bounds", + requestedSchema: { + type: "object", + properties: { + exclusiveBoth: { + type: "number", + description: "Range", + exclusiveMinimum: 0, + exclusiveMaximum: 10, + }, + minInclusiveMaxExclusive: { + type: "integer", + description: "Slice", + minimum: 0, + exclusiveMaximum: 10, + }, + minExclusiveMaxInclusive: { + type: "number", + description: "Window", + exclusiveMinimum: 0, + maximum: 10, + }, + onlyExclusiveMin: { + type: "integer", + description: "Count", + exclusiveMinimum: 5, + }, + onlyExclusiveMax: { + type: "number", + description: "Cap", + exclusiveMaximum: 100, + }, + }, + }, + }); + + assert.equal(questions[0]?.question, "Range (greater than 0 and less than 10)"); + assert.equal(questions[1]?.question, "Slice (at least 0 and less than 10)"); + assert.equal(questions[2]?.question, "Window (greater than 0 and at most 10)"); + assert.equal(questions[3]?.question, "Count (greater than 5)"); + assert.equal(questions[4]?.question, "Cap (less than 100)"); + }); + it("resolves elicitation responses for cancel, accept, and decline on missing required fields", () => { // 1. Cancel on null answers const cancelResult = resolveElicitationResponse({ diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index e4e9e4ae82c8..61814c5ee2a8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -1204,16 +1204,51 @@ export function parseElicitationQuestions(params: { options.length > 0 || record?.type === "boolean" ? false : undefined; let questionText = nonEmptyText(record?.description, params.message); - const declaredType = typeof record?.type === "string" ? record.type : undefined; + let declaredType = typeof record?.type === "string" ? record.type : undefined; + if ( + declaredType === undefined && + Array.isArray(record?.enum) && + record.enum.length > 0 && + record.enum.every((entry) => typeof entry === "number" && Number.isFinite(entry)) + ) { + declaredType = record.enum.every((entry) => Number.isInteger(entry)) ? "integer" : "number"; + } if (declaredType === "number" || declaredType === "integer") { const hasMin = typeof record?.minimum === "number"; const hasMax = typeof record?.maximum === "number"; - if (hasMin && hasMax) { - questionText = `${questionText} (between ${record!.minimum} and ${record!.maximum})`; - } else if (hasMin) { - questionText = `${questionText} (minimum ${record!.minimum})`; - } else if (hasMax) { - questionText = `${questionText} (maximum ${record!.maximum})`; + const hasExclusiveMin = typeof record?.exclusiveMinimum === "number"; + const hasExclusiveMax = typeof record?.exclusiveMaximum === "number"; + + const lowerBound = hasMin + ? { value: record!.minimum as number, inclusive: true } + : hasExclusiveMin + ? { value: record!.exclusiveMinimum as number, inclusive: false } + : undefined; + + const upperBound = hasMax + ? { value: record!.maximum as number, inclusive: true } + : hasExclusiveMax + ? { value: record!.exclusiveMaximum as number, inclusive: false } + : undefined; + + if (lowerBound !== undefined && upperBound !== undefined) { + if (lowerBound.inclusive && upperBound.inclusive) { + questionText = `${questionText} (between ${lowerBound.value} and ${upperBound.value})`; + } else if (lowerBound.inclusive && !upperBound.inclusive) { + questionText = `${questionText} (at least ${lowerBound.value} and less than ${upperBound.value})`; + } else if (!lowerBound.inclusive && upperBound.inclusive) { + questionText = `${questionText} (greater than ${lowerBound.value} and at most ${upperBound.value})`; + } else { + questionText = `${questionText} (greater than ${lowerBound.value} and less than ${upperBound.value})`; + } + } else if (lowerBound !== undefined) { + questionText = lowerBound.inclusive + ? `${questionText} (minimum ${lowerBound.value})` + : `${questionText} (greater than ${lowerBound.value})`; + } else if (upperBound !== undefined) { + questionText = upperBound.inclusive + ? `${questionText} (maximum ${upperBound.value})` + : `${questionText} (less than ${upperBound.value})`; } } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 66da5052c45c..8fcdf5bae2f9 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -88,6 +88,21 @@ describe("resolvePendingUserInputAnswer", () => { ).toEqual(["Server", "Web"]); }); + it("allows submitting an empty array when minItems is 0", () => { + const zeroMinQuestion = { ...multiSelectQuestion, minItems: 0 }; + expect( + resolvePendingUserInputAnswer(zeroMinQuestion, { + selectedOptionValues: [], + }), + ).toEqual([]); + expect(resolvePendingUserInputAnswer(zeroMinQuestion, undefined)).toEqual([]); + expect( + resolvePendingUserInputAnswer(multiSelectQuestion, { + selectedOptionValues: [], + }), + ).toBeNull(); + }); + it("clears the preset selection when a custom answer is entered", () => { expect( setPendingUserInputCustomAnswer( @@ -167,8 +182,11 @@ describe("togglePendingUserInputOptionSelection", () => { const two = togglePendingUserInputOptionSelection(question, one, "Desktop"); expect(two.selectedOptionValues).toEqual(["Server", "Desktop"]); - const three = togglePendingUserInputOptionSelection(question, two, "Customers"); + const withCustom = { selectedOptionValues: ["Server", "Desktop"], customAnswer: "preserve me" }; + const three = togglePendingUserInputOptionSelection(question, withCustom, "Customers"); + expect(three).toBe(withCustom); expect(three.selectedOptionValues).toEqual(["Server", "Desktop"]); + expect(three.customAnswer).toBe("preserve me"); const deselected = togglePendingUserInputOptionSelection(question, two, "Server"); expect(deselected.selectedOptionValues).toEqual(["Desktop"]); @@ -221,6 +239,13 @@ describe("buildPendingUserInputAnswers", () => { }); }); + it("submits empty array for required multi-select questions with minItems 0", () => { + const zeroMin = { ...multiSelectQuestion, minItems: 0 }; + expect(buildPendingUserInputAnswers([zeroMin], {})).toEqual({ + areas: [], + }); + }); + it("returns null when any question is unanswered", () => { expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull(); }); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 95f1739087da..80eca1403f4c 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -54,11 +54,13 @@ export function resolvePendingUserInputAnswer( (value) => question.options.some((option) => (option.value ?? option.label) === value), ); if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 - ? "" - : null; + if (selectedOptionValues.length > 0) { + return selectedOptionValues; + } + if (question.minItems === 0) { + return []; + } + return question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null; } return ( @@ -117,10 +119,7 @@ export function togglePendingUserInputOptionSelection( question.maxItems !== undefined && selectedOptionValues.length >= question.maxItems ) { - return { - customAnswer: "", - ...(selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; + return draft ?? {}; } const nextSelectedOptionValues = isSelected ? selectedOptionValues.filter((value) => value !== optionValue) diff --git a/packages/contracts/src/userInputValidation.test.ts b/packages/contracts/src/userInputValidation.test.ts index 17ec1051ade4..224165016430 100644 --- a/packages/contracts/src/userInputValidation.test.ts +++ b/packages/contracts/src/userInputValidation.test.ts @@ -129,6 +129,18 @@ describe("normalizeUserInputAnswer", () => { ok: true, value: ["a", "b"], }); + expect( + normalizeUserInputAnswer(question({ valueType: "array" }), [ + "a", + 1, + true, + { nope: true }, + null, + ]), + ).toEqual({ + ok: true, + value: ["a", "1", "true"], + }); expect( normalizeUserInputAnswer(question({ valueType: "array", maxItems: 1 }), ["a", "b"]), ).toEqual({ ok: false, message: "Select at most 1 option." }); From 0cdde12d17ae10908f66eeb199f29201e01a8d68 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:20:58 +0530 Subject: [PATCH 14/15] fix(server): update process spawner import for effect 4 in antigravity tests --- apps/server/src/provider/acp/AntigravityAcpSupport.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 821153221187..4bbfd55112d6 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -13,7 +13,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/process"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { antigravityPermissionMode, From dec647d915cfe50e6ac0d7455439ca04732e47d0 Mon Sep 17 00:00:00 2001 From: Dipangshu Roy <57279309+Droyder7@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:36:52 +0530 Subject: [PATCH 15/15] fix(server): update SqlClient import in ThreadStop test for effect 4 --- apps/server/src/orchestration-v2/ThreadStop.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 077831075985..3320830e409b 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -16,7 +16,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";