Skip to content

Commit 2177da0

Browse files
authored
Merge pull request #2 from Jaaneek/stack/grok-model-change-policy
Handle Grok xAI ask_user_question
2 parents f50d50a + 30fcbf6 commit 2177da0

4 files changed

Lines changed: 342 additions & 8 deletions

File tree

apps/server/scripts/acp-mock-agent.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const emitInterleavedAssistantToolCalls =
1818
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
1919
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
2020
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
21+
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
2122
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2223
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2324
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
@@ -556,6 +557,39 @@ const program = Effect.gen(function* () {
556557
return { stopReason: "end_turn" };
557558
}
558559

560+
if (emitXAiAskUserQuestion) {
561+
const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
562+
method: "x.ai/ask_user_question",
563+
params: {
564+
sessionId: requestedSessionId,
565+
toolCallId: "ask-user-question-tool-call-1",
566+
questions: [
567+
{
568+
question: "Which scope should Grok use?",
569+
options: [
570+
{ label: "Workspace", description: "Use the current workspace" },
571+
{ label: "Session", description: "Only use this session" },
572+
],
573+
},
574+
],
575+
mode: "default",
576+
},
577+
});
578+
if (
579+
typeof result !== "object" ||
580+
result === null ||
581+
!("outcome" in result) ||
582+
result.outcome !== "accepted" ||
583+
!("answers" in result) ||
584+
typeof result.answers !== "object" ||
585+
result.answers === null
586+
) {
587+
throw new Error("Expected _x.ai/ask_user_question response outcome.");
588+
}
589+
590+
return { stopReason: "end_turn" };
591+
}
592+
559593
yield* agent.client.sessionUpdate({
560594
sessionId: requestedSessionId,
561595
update: {

apps/server/src/provider/Layers/GrokAdapter.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,67 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
274274
}),
275275
);
276276

277+
it.effect("handles xAI ask_user_question extension requests", () =>
278+
Effect.gen(function* () {
279+
const threadId = ThreadId.make("grok-xai-ask-user-question");
280+
const wrapperPath = yield* Effect.promise(() =>
281+
makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION: "1" }),
282+
);
283+
const adapter = yield* makeTestAdapter(wrapperPath);
284+
const requested =
285+
yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "user-input.requested" }>>();
286+
const resolved =
287+
yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "user-input.resolved" }>>();
288+
289+
const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
290+
if (String(event.threadId) !== String(threadId)) {
291+
return Effect.void;
292+
}
293+
if (event.type === "user-input.requested") {
294+
return Deferred.succeed(requested, event).pipe(Effect.ignore);
295+
}
296+
if (event.type === "user-input.resolved") {
297+
return Deferred.succeed(resolved, event).pipe(Effect.ignore);
298+
}
299+
return Effect.void;
300+
}).pipe(Effect.forkChild);
301+
302+
yield* adapter.startSession({
303+
threadId,
304+
provider: ProviderDriverKind.make("grok"),
305+
cwd: process.cwd(),
306+
runtimeMode: "full-access",
307+
});
308+
309+
const sendTurnFiber = yield* adapter
310+
.sendTurn({ threadId, input: "ask before continuing", attachments: [] })
311+
.pipe(Effect.forkChild);
312+
313+
const requestedEvent = yield* Deferred.await(requested);
314+
assert.equal(requestedEvent.payload.questions.length, 1);
315+
assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?");
316+
assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?");
317+
assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question");
318+
319+
yield* adapter.respondToUserInput(
320+
threadId,
321+
ApprovalRequestId.make(String(requestedEvent.requestId)),
322+
{
323+
"Which scope should Grok use?": "Workspace",
324+
},
325+
);
326+
327+
const resolvedEvent = yield* Deferred.await(resolved);
328+
assert.deepEqual(resolvedEvent.payload.answers, {
329+
"Which scope should Grok use?": "Workspace",
330+
});
331+
yield* Fiber.join(sendTurnFiber);
332+
333+
yield* Fiber.interrupt(eventsFiber);
334+
yield* adapter.stopSession(threadId);
335+
}),
336+
);
337+
277338
it.effect("continues streaming events when native notification logging fails", () =>
278339
Effect.gen(function* () {
279340
const threadId = ThreadId.make("grok-native-log-failure");

apps/server/src/provider/Layers/GrokAdapter.ts

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type ProviderApprovalDecision,
66
type ProviderRuntimeEvent,
77
type ProviderSession,
8+
type ProviderUserInputAnswers,
89
ProviderDriverKind,
910
ProviderInstanceId,
1011
RuntimeRequestId,
@@ -56,6 +57,11 @@ import {
5657
makeGrokAcpRuntime,
5758
resolveGrokAcpBaseModelId,
5859
} from "../acp/GrokAcpSupport.ts";
60+
import {
61+
extractXAiAskUserQuestions,
62+
makeXAiAskUserQuestionResponse,
63+
XAiAskUserQuestionRequest,
64+
} from "../acp/XAiAcpExtension.ts";
5965
import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
6066
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
6167

@@ -73,13 +79,17 @@ export interface GrokAdapterLiveOptions {
7379
readonly environment?: NodeJS.ProcessEnv;
7480
readonly nativeEventLogPath?: string;
7581
readonly nativeEventLogger?: EventNdjsonLogger;
76-
readonly instanceId?: typeof ProviderInstanceId.Type;
82+
readonly instanceId?: ProviderInstanceId;
7783
}
7884

7985
interface PendingApproval {
8086
readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
8187
}
8288

89+
interface PendingUserInput {
90+
readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
91+
}
92+
8393
interface GrokSessionContext {
8494
readonly threadId: ThreadId;
8595
readonly acpSessionId: string;
@@ -88,6 +98,7 @@ interface GrokSessionContext {
8898
readonly acp: AcpSessionRuntimeShape;
8999
notificationFiber: Fiber.Fiber<void, never> | undefined;
90100
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
101+
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
91102
turns: Array<{ id: TurnId; items: Array<unknown> }>;
92103
lastPlanFingerprint: string | undefined;
93104
activeTurnId: TurnId | undefined;
@@ -105,6 +116,16 @@ function settlePendingApprovalsAsCancelled(
105116
);
106117
}
107118

119+
function settlePendingUserInputsAsEmptyAnswers(
120+
pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,
121+
): Effect.Effect<void> {
122+
return Effect.forEach(
123+
Array.from(pendingUserInputs.values()),
124+
(pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
125+
{ discard: true },
126+
);
127+
}
128+
108129
function isRecord(value: unknown): value is Record<string, unknown> {
109130
return typeof value === "object" && value !== null && !Array.isArray(value);
110131
}
@@ -287,6 +308,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
287308
if (ctx.stopped) return;
288309
ctx.stopped = true;
289310
yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
311+
yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
290312
if (ctx.notificationFiber) {
291313
yield* Fiber.interrupt(ctx.notificationFiber);
292314
}
@@ -329,6 +351,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
329351
}
330352

331353
const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
354+
const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
332355
const sessionScope = yield* Scope.make("sequential");
333356
let sessionScopeTransferred = false;
334357
yield* Effect.addFinalizer(() =>
@@ -363,6 +386,53 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
363386
),
364387
);
365388
const started = yield* Effect.gen(function* () {
389+
yield* Effect.forEach(
390+
["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
391+
(method) =>
392+
acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) =>
393+
mapAcpCallbackFailure(
394+
Effect.gen(function* () {
395+
yield* logNative(input.threadId, method, params);
396+
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
397+
const runtimeRequestId = RuntimeRequestId.make(requestId);
398+
const answers = yield* Deferred.make<ProviderUserInputAnswers>();
399+
pendingUserInputs.set(requestId, { answers });
400+
yield* offerRuntimeEvent({
401+
type: "user-input.requested",
402+
...(yield* makeEventStamp()),
403+
provider: PROVIDER,
404+
threadId: input.threadId,
405+
turnId: sessions.get(input.threadId)?.activeTurnId,
406+
requestId: runtimeRequestId,
407+
payload: { questions: extractXAiAskUserQuestions(params) },
408+
raw: {
409+
source: "acp.grok.extension",
410+
method,
411+
payload: params,
412+
},
413+
});
414+
const resolved = yield* Deferred.await(answers);
415+
pendingUserInputs.delete(requestId);
416+
yield* offerRuntimeEvent({
417+
type: "user-input.resolved",
418+
...(yield* makeEventStamp()),
419+
provider: PROVIDER,
420+
threadId: input.threadId,
421+
turnId: sessions.get(input.threadId)?.activeTurnId,
422+
requestId: runtimeRequestId,
423+
payload: { answers: resolved },
424+
raw: {
425+
source: "acp.grok.extension",
426+
method,
427+
payload: params,
428+
},
429+
});
430+
return makeXAiAskUserQuestionResponse(resolved);
431+
}),
432+
),
433+
),
434+
{ discard: true },
435+
);
366436
yield* acp.handleRequestPermission((params) =>
367437
mapAcpCallbackFailure(
368438
Effect.gen(function* () {
@@ -470,6 +540,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
470540
acp,
471541
notificationFiber: undefined,
472542
pendingApprovals,
543+
pendingUserInputs,
473544
turns: [],
474545
lastPlanFingerprint: undefined,
475546
activeTurnId: undefined,
@@ -733,6 +804,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
733804
Effect.gen(function* () {
734805
const ctx = yield* requireSession(threadId);
735806
yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
807+
yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
736808
yield* Effect.ignore(
737809
ctx.acp.cancel.pipe(
738810
Effect.mapError((error) =>
@@ -760,14 +832,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
760832
yield* Deferred.succeed(pending.decision, decision);
761833
});
762834

763-
const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (threadId, requestId) =>
835+
const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (
836+
threadId,
837+
requestId,
838+
answers,
839+
) =>
764840
Effect.gen(function* () {
765-
yield* requireSession(threadId);
766-
return yield* new ProviderAdapterRequestError({
767-
provider: PROVIDER,
768-
method: "user-input/respond",
769-
detail: `Grok has no pending user-input request: ${requestId}`,
770-
});
841+
const ctx = yield* requireSession(threadId);
842+
const pending = ctx.pendingUserInputs.get(requestId);
843+
if (!pending) {
844+
return yield* new ProviderAdapterRequestError({
845+
provider: PROVIDER,
846+
method: "_x.ai/ask_user_question",
847+
detail: `Unknown pending user-input request: ${requestId}`,
848+
});
849+
}
850+
yield* Deferred.succeed(pending.answers, answers);
771851
});
772852

773853
const readThread: GrokAdapterShape["readThread"] = (threadId) =>

0 commit comments

Comments
 (0)