From 9115857d487f8ff48a0cbd456954e45dbb66f7c4 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 5 Jun 2026 15:04:34 +0100 Subject: [PATCH] Tighten Grok xAI question payload handling. --- .../src/provider/acp/XAiAcpExtension.test.ts | 72 ++++++++ .../src/provider/acp/XAiAcpExtension.ts | 169 ++++++------------ 2 files changed, 122 insertions(+), 119 deletions(-) create mode 100644 apps/server/src/provider/acp/XAiAcpExtension.test.ts diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts new file mode 100644 index 00000000000..4810b4b352d --- /dev/null +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import * as Schema from "effect/Schema"; + +import { extractXAiAskUserQuestions, XAiAskUserQuestionRequest } from "./XAiAcpExtension.ts"; + +const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); + +describe("XAiAcpExtension", () => { + it("extracts questions from the real xAI ask_user_question payload shape", () => { + const questions = extractXAiAskUserQuestions({ + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + id: "scope", + question: "Which scope should Grok use?", + options: [ + { label: "Workspace", description: "Use the current workspace" }, + { label: "Session", description: "Only use this session" }, + ], + }, + ], + }); + + expect(questions).toEqual([ + { + id: "scope", + header: "Question", + question: "Which scope should Grok use?", + multiSelect: false, + options: [ + { label: "Workspace", description: "Use the current workspace" }, + { label: "Session", description: "Only use this session" }, + ], + }, + ]); + }); + + it("extracts questions from wrapped _x.ai extension payloads", () => { + const payload = { + method: "x.ai/ask_user_question", + params: { + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "plan", + questions: [ + { + question: "Which changes should be included?", + multiSelect: true, + options: [{ label: "Tests" }, { label: "Docs" }], + }, + ], + }, + }; + const decoded = decodeXAiAskUserQuestionRequest(payload); + const questions = extractXAiAskUserQuestions(decoded); + + expect(questions).toEqual([ + { + id: "Which changes should be included?", + header: "Question", + question: "Which changes should be included?", + multiSelect: true, + options: [ + { label: "Tests", description: "Tests" }, + { label: "Docs", description: "Docs" }, + ], + }, + ]); + }); +}); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 5cbf5327783..00d617f3f13 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,138 +1,69 @@ import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; -export const XAiAskUserQuestionRequest = Schema.Unknown; +const XAiAskUserQuestionOption = Schema.Struct({ + label: Schema.String, + description: Schema.optional(Schema.String), + preview: Schema.optional(Schema.String), + id: Schema.optional(Schema.String), +}); -type UnknownRecord = Record; +const XAiAskUserQuestion = Schema.Struct({ + id: Schema.optional(Schema.String), + question: Schema.String, + options: Schema.Array(XAiAskUserQuestionOption), + multiSelect: Schema.optional(Schema.Boolean), +}); -function trimmed(value: string | undefined): string | undefined { - const text = value?.trim(); - return text && text.length > 0 ? text : undefined; -} +const XAiAskUserQuestionParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.String, + questions: Schema.Array(XAiAskUserQuestion), + mode: Schema.Union([Schema.Literal("default"), Schema.Literal("plan")]), +}); -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const XAiWrappedAskUserQuestionParams = Schema.Struct({ + method: Schema.Literal("x.ai/ask_user_question"), + params: XAiAskUserQuestionParams, +}); -function stringField(record: UnknownRecord, keys: ReadonlyArray): string | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string") { - const text = trimmed(value); - if (text) { - return text; - } - } - } - return undefined; -} +export const XAiAskUserQuestionRequest = Schema.Unknown; -function booleanField(record: UnknownRecord, keys: ReadonlyArray): boolean | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "boolean") { - return value; - } - } - return undefined; -} +type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; -function arrayField(record: UnknownRecord, keys: ReadonlyArray): ReadonlyArray { - for (const key of keys) { - const value = record[key]; - if (Array.isArray(value)) { - return value; - } - } - return []; -} +const decodeXAiAskUserQuestionParams = Schema.decodeUnknownSync(XAiAskUserQuestionParams); +const decodeXAiWrappedAskUserQuestionParamsExit = Schema.decodeUnknownExit( + XAiWrappedAskUserQuestionParams, +); -function nestedRecord( - record: UnknownRecord, - keys: ReadonlyArray, -): UnknownRecord | undefined { - for (const key of keys) { - const value = record[key]; - if (isRecord(value)) { - return value; - } - } - return undefined; +function trimmed(value: string | undefined): string | undefined { + const text = value?.trim(); + return text && text.length > 0 ? text : undefined; } -function unwrapParams(params: unknown): UnknownRecord { - if (!isRecord(params)) { - return {}; +function unwrapAskUserQuestionParams(params: unknown): XAiAskUserQuestionRequestParams { + const wrapped = decodeXAiWrappedAskUserQuestionParamsExit(params); + if (Exit.isSuccess(wrapped)) { + return wrapped.value.params; } - const request = nestedRecord(params, ["request"]); - const requestInput = request ? nestedRecord(request, ["input", "arguments", "args"]) : undefined; - return nestedRecord(params, ["input", "arguments", "args", "params"]) ?? requestInput ?? params; -} - -function extractOptionLabel(option: unknown): string | undefined { - return typeof option === "string" - ? trimmed(option) - : isRecord(option) - ? stringField(option, ["label", "value", "id", "text", "title", "name"]) - : undefined; -} - -function extractOptions(options: ReadonlyArray) { - const extracted = (options ?? []).flatMap((option) => { - const label = extractOptionLabel(option); - if (!label) { - return []; - } - const description = - typeof option === "string" - ? label - : isRecord(option) - ? (stringField(option, ["description", "detail", "subtitle"]) ?? label) - : label; - return [{ label, description }]; - }); - return extracted.length > 0 ? extracted : [{ label: "OK", description: "Continue" }]; -} - -function extractQuestion( - question: unknown, - fallbackTitle: string | undefined, - index: number, -): UserInputQuestion { - const record = isRecord(question) ? question : {}; - const nestedQuestion = nestedRecord(record, ["question"]); - const questionSource = nestedQuestion ?? record; - const questionText = - (typeof question === "string" ? trimmed(question) : undefined) ?? - stringField(questionSource, ["question", "prompt", "text", "content", "message"]) ?? - fallbackTitle ?? - `Question ${index + 1}`; - const id = stringField(questionSource, ["id", "questionId", "key"]) ?? questionText; - return { - id, - header: - stringField(questionSource, ["header", "title", "label"]) ?? fallbackTitle ?? "Question", - question: questionText, - multiSelect: - booleanField(questionSource, ["multiSelect", "allowMultiple", "allow_multiple"]) === true, - options: extractOptions(arrayField(questionSource, ["options", "choices", "answers"])), - }; + return decodeXAiAskUserQuestionParams(params); } export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray { - const root = unwrapParams(params); - const title = stringField(root, ["title", "header", "toolTitle"]); - const questions = arrayField(root, ["questions", "items", "prompts"]); - if (questions.length > 0) { - return questions.map((question, index) => extractQuestion(question, title, index)); - } - const singleQuestion = nestedRecord(root, ["question"]) ?? root; - const singleQuestionOptions = arrayField(root, ["options", "choices", "answers"]); - const question = - singleQuestion === root || singleQuestionOptions.length === 0 - ? singleQuestion - : { ...singleQuestion, options: singleQuestionOptions }; - return [extractQuestion(question, title, 0)]; + return unwrapAskUserQuestionParams(params).questions.map((question) => ({ + id: question.id ?? question.question, + header: "Question", + question: question.question, + multiSelect: question.multiSelect === true, + options: + question.options.length > 0 + ? question.options.map((option) => ({ + label: option.label, + description: option.description ?? option.label, + })) + : [{ label: "OK", description: "Continue" }], + })); } function answerValues(answer: unknown): ReadonlyArray {