Skip to content

Commit 9115857

Browse files
committed
Tighten Grok xAI question payload handling.
1 parent 30fcbf6 commit 9115857

2 files changed

Lines changed: 122 additions & 119 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from "vitest";
2+
import * as Schema from "effect/Schema";
3+
4+
import { extractXAiAskUserQuestions, XAiAskUserQuestionRequest } from "./XAiAcpExtension.ts";
5+
6+
const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
7+
8+
describe("XAiAcpExtension", () => {
9+
it("extracts questions from the real xAI ask_user_question payload shape", () => {
10+
const questions = extractXAiAskUserQuestions({
11+
sessionId: "session-1",
12+
toolCallId: "tool-call-1",
13+
mode: "default",
14+
questions: [
15+
{
16+
id: "scope",
17+
question: "Which scope should Grok use?",
18+
options: [
19+
{ label: "Workspace", description: "Use the current workspace" },
20+
{ label: "Session", description: "Only use this session" },
21+
],
22+
},
23+
],
24+
});
25+
26+
expect(questions).toEqual([
27+
{
28+
id: "scope",
29+
header: "Question",
30+
question: "Which scope should Grok use?",
31+
multiSelect: false,
32+
options: [
33+
{ label: "Workspace", description: "Use the current workspace" },
34+
{ label: "Session", description: "Only use this session" },
35+
],
36+
},
37+
]);
38+
});
39+
40+
it("extracts questions from wrapped _x.ai extension payloads", () => {
41+
const payload = {
42+
method: "x.ai/ask_user_question",
43+
params: {
44+
sessionId: "session-1",
45+
toolCallId: "tool-call-1",
46+
mode: "plan",
47+
questions: [
48+
{
49+
question: "Which changes should be included?",
50+
multiSelect: true,
51+
options: [{ label: "Tests" }, { label: "Docs" }],
52+
},
53+
],
54+
},
55+
};
56+
const decoded = decodeXAiAskUserQuestionRequest(payload);
57+
const questions = extractXAiAskUserQuestions(decoded);
58+
59+
expect(questions).toEqual([
60+
{
61+
id: "Which changes should be included?",
62+
header: "Question",
63+
question: "Which changes should be included?",
64+
multiSelect: true,
65+
options: [
66+
{ label: "Tests", description: "Tests" },
67+
{ label: "Docs", description: "Docs" },
68+
],
69+
},
70+
]);
71+
});
72+
});

apps/server/src/provider/acp/XAiAcpExtension.ts

Lines changed: 50 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,69 @@
11
import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
2+
import * as Exit from "effect/Exit";
23
import * as Schema from "effect/Schema";
34

4-
export const XAiAskUserQuestionRequest = Schema.Unknown;
5+
const XAiAskUserQuestionOption = Schema.Struct({
6+
label: Schema.String,
7+
description: Schema.optional(Schema.String),
8+
preview: Schema.optional(Schema.String),
9+
id: Schema.optional(Schema.String),
10+
});
511

6-
type UnknownRecord = Record<string, unknown>;
12+
const XAiAskUserQuestion = Schema.Struct({
13+
id: Schema.optional(Schema.String),
14+
question: Schema.String,
15+
options: Schema.Array(XAiAskUserQuestionOption),
16+
multiSelect: Schema.optional(Schema.Boolean),
17+
});
718

8-
function trimmed(value: string | undefined): string | undefined {
9-
const text = value?.trim();
10-
return text && text.length > 0 ? text : undefined;
11-
}
19+
const XAiAskUserQuestionParams = Schema.Struct({
20+
sessionId: Schema.String,
21+
toolCallId: Schema.String,
22+
questions: Schema.Array(XAiAskUserQuestion),
23+
mode: Schema.Union([Schema.Literal("default"), Schema.Literal("plan")]),
24+
});
1225

13-
function isRecord(value: unknown): value is UnknownRecord {
14-
return typeof value === "object" && value !== null && !Array.isArray(value);
15-
}
26+
const XAiWrappedAskUserQuestionParams = Schema.Struct({
27+
method: Schema.Literal("x.ai/ask_user_question"),
28+
params: XAiAskUserQuestionParams,
29+
});
1630

17-
function stringField(record: UnknownRecord, keys: ReadonlyArray<string>): string | undefined {
18-
for (const key of keys) {
19-
const value = record[key];
20-
if (typeof value === "string") {
21-
const text = trimmed(value);
22-
if (text) {
23-
return text;
24-
}
25-
}
26-
}
27-
return undefined;
28-
}
31+
export const XAiAskUserQuestionRequest = Schema.Unknown;
2932

30-
function booleanField(record: UnknownRecord, keys: ReadonlyArray<string>): boolean | undefined {
31-
for (const key of keys) {
32-
const value = record[key];
33-
if (typeof value === "boolean") {
34-
return value;
35-
}
36-
}
37-
return undefined;
38-
}
33+
type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type;
3934

40-
function arrayField(record: UnknownRecord, keys: ReadonlyArray<string>): ReadonlyArray<unknown> {
41-
for (const key of keys) {
42-
const value = record[key];
43-
if (Array.isArray(value)) {
44-
return value;
45-
}
46-
}
47-
return [];
48-
}
35+
const decodeXAiAskUserQuestionParams = Schema.decodeUnknownSync(XAiAskUserQuestionParams);
36+
const decodeXAiWrappedAskUserQuestionParamsExit = Schema.decodeUnknownExit(
37+
XAiWrappedAskUserQuestionParams,
38+
);
4939

50-
function nestedRecord(
51-
record: UnknownRecord,
52-
keys: ReadonlyArray<string>,
53-
): UnknownRecord | undefined {
54-
for (const key of keys) {
55-
const value = record[key];
56-
if (isRecord(value)) {
57-
return value;
58-
}
59-
}
60-
return undefined;
40+
function trimmed(value: string | undefined): string | undefined {
41+
const text = value?.trim();
42+
return text && text.length > 0 ? text : undefined;
6143
}
6244

63-
function unwrapParams(params: unknown): UnknownRecord {
64-
if (!isRecord(params)) {
65-
return {};
45+
function unwrapAskUserQuestionParams(params: unknown): XAiAskUserQuestionRequestParams {
46+
const wrapped = decodeXAiWrappedAskUserQuestionParamsExit(params);
47+
if (Exit.isSuccess(wrapped)) {
48+
return wrapped.value.params;
6649
}
67-
const request = nestedRecord(params, ["request"]);
68-
const requestInput = request ? nestedRecord(request, ["input", "arguments", "args"]) : undefined;
69-
return nestedRecord(params, ["input", "arguments", "args", "params"]) ?? requestInput ?? params;
70-
}
71-
72-
function extractOptionLabel(option: unknown): string | undefined {
73-
return typeof option === "string"
74-
? trimmed(option)
75-
: isRecord(option)
76-
? stringField(option, ["label", "value", "id", "text", "title", "name"])
77-
: undefined;
78-
}
79-
80-
function extractOptions(options: ReadonlyArray<unknown>) {
81-
const extracted = (options ?? []).flatMap((option) => {
82-
const label = extractOptionLabel(option);
83-
if (!label) {
84-
return [];
85-
}
86-
const description =
87-
typeof option === "string"
88-
? label
89-
: isRecord(option)
90-
? (stringField(option, ["description", "detail", "subtitle"]) ?? label)
91-
: label;
92-
return [{ label, description }];
93-
});
94-
return extracted.length > 0 ? extracted : [{ label: "OK", description: "Continue" }];
95-
}
96-
97-
function extractQuestion(
98-
question: unknown,
99-
fallbackTitle: string | undefined,
100-
index: number,
101-
): UserInputQuestion {
102-
const record = isRecord(question) ? question : {};
103-
const nestedQuestion = nestedRecord(record, ["question"]);
104-
const questionSource = nestedQuestion ?? record;
105-
const questionText =
106-
(typeof question === "string" ? trimmed(question) : undefined) ??
107-
stringField(questionSource, ["question", "prompt", "text", "content", "message"]) ??
108-
fallbackTitle ??
109-
`Question ${index + 1}`;
110-
const id = stringField(questionSource, ["id", "questionId", "key"]) ?? questionText;
111-
return {
112-
id,
113-
header:
114-
stringField(questionSource, ["header", "title", "label"]) ?? fallbackTitle ?? "Question",
115-
question: questionText,
116-
multiSelect:
117-
booleanField(questionSource, ["multiSelect", "allowMultiple", "allow_multiple"]) === true,
118-
options: extractOptions(arrayField(questionSource, ["options", "choices", "answers"])),
119-
};
50+
return decodeXAiAskUserQuestionParams(params);
12051
}
12152

12253
export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray<UserInputQuestion> {
123-
const root = unwrapParams(params);
124-
const title = stringField(root, ["title", "header", "toolTitle"]);
125-
const questions = arrayField(root, ["questions", "items", "prompts"]);
126-
if (questions.length > 0) {
127-
return questions.map((question, index) => extractQuestion(question, title, index));
128-
}
129-
const singleQuestion = nestedRecord(root, ["question"]) ?? root;
130-
const singleQuestionOptions = arrayField(root, ["options", "choices", "answers"]);
131-
const question =
132-
singleQuestion === root || singleQuestionOptions.length === 0
133-
? singleQuestion
134-
: { ...singleQuestion, options: singleQuestionOptions };
135-
return [extractQuestion(question, title, 0)];
54+
return unwrapAskUserQuestionParams(params).questions.map((question) => ({
55+
id: question.id ?? question.question,
56+
header: "Question",
57+
question: question.question,
58+
multiSelect: question.multiSelect === true,
59+
options:
60+
question.options.length > 0
61+
? question.options.map((option) => ({
62+
label: option.label,
63+
description: option.description ?? option.label,
64+
}))
65+
: [{ label: "OK", description: "Continue" }],
66+
}));
13667
}
13768

13869
function answerValues(answer: unknown): ReadonlyArray<string> {

0 commit comments

Comments
 (0)