Skip to content

Commit b159693

Browse files
committed
Handle Grok xAI interactive extensions.
1 parent 9649d20 commit b159693

4 files changed

Lines changed: 451 additions & 8 deletions

File tree

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ 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";
22+
const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
2123
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2224
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2325
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
@@ -556,6 +558,60 @@ const program = Effect.gen(function* () {
556558
return { stopReason: "end_turn" };
557559
}
558560

561+
if (emitXAiAskUserQuestion) {
562+
const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
563+
method: "x.ai/ask_user_question",
564+
params: {
565+
sessionId: requestedSessionId,
566+
toolCallId: "ask-user-question-tool-call-1",
567+
questions: [
568+
{
569+
question: "Which scope should Grok use?",
570+
options: [
571+
{ label: "Workspace", description: "Use the current workspace" },
572+
{ label: "Session", description: "Only use this session" },
573+
],
574+
},
575+
],
576+
mode: "default",
577+
},
578+
});
579+
if (
580+
typeof result !== "object" ||
581+
result === null ||
582+
!("outcome" in result) ||
583+
result.outcome !== "accepted" ||
584+
!("answers" in result) ||
585+
typeof result.answers !== "object" ||
586+
result.answers === null
587+
) {
588+
throw new Error("Expected _x.ai/ask_user_question response outcome.");
589+
}
590+
591+
return { stopReason: "end_turn" };
592+
}
593+
594+
if (emitXAiExitPlanMode) {
595+
const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", {
596+
method: "x.ai/exit_plan_mode",
597+
params: {
598+
sessionId: requestedSessionId,
599+
toolCallId: "exit-plan-mode-tool-call-1",
600+
planContent: "# Grok plan\n\n- Inspect the workspace\n- Apply the fix",
601+
},
602+
});
603+
if (
604+
typeof result !== "object" ||
605+
result === null ||
606+
!("outcome" in result) ||
607+
result.outcome !== "approved"
608+
) {
609+
throw new Error("Expected _x.ai/exit_plan_mode approved outcome.");
610+
}
611+
612+
return { stopReason: "end_turn" };
613+
}
614+
559615
yield* agent.client.sessionUpdate({
560616
sessionId: requestedSessionId,
561617
update: {

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,108 @@ 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+
338+
it.effect("handles xAI exit_plan_mode extension requests", () =>
339+
Effect.gen(function* () {
340+
const threadId = ThreadId.make("grok-xai-exit-plan-mode");
341+
const wrapperPath = yield* Effect.promise(() =>
342+
makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }),
343+
);
344+
const adapter = yield* makeTestAdapter(wrapperPath);
345+
const proposedPlan =
346+
yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.proposed.completed" }>>();
347+
348+
const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
349+
if (
350+
String(event.threadId) !== String(threadId) ||
351+
event.type !== "turn.proposed.completed"
352+
) {
353+
return Effect.void;
354+
}
355+
return Deferred.succeed(proposedPlan, event).pipe(Effect.ignore);
356+
}).pipe(Effect.forkChild);
357+
358+
yield* adapter.startSession({
359+
threadId,
360+
provider: ProviderDriverKind.make("grok"),
361+
cwd: process.cwd(),
362+
runtimeMode: "full-access",
363+
});
364+
365+
yield* adapter.sendTurn({ threadId, input: "propose a plan", attachments: [] });
366+
367+
const event = yield* Deferred.await(proposedPlan);
368+
assert.equal(
369+
event.payload.planMarkdown,
370+
"# Grok plan\n\n- Inspect the workspace\n- Apply the fix",
371+
);
372+
assert.equal(event.raw?.method, "_x.ai/exit_plan_mode");
373+
374+
yield* Fiber.interrupt(eventsFiber);
375+
yield* adapter.stopSession(threadId);
376+
}),
377+
);
378+
277379
it.effect("continues streaming events when native notification logging fails", () =>
278380
Effect.gen(function* () {
279381
const threadId = ThreadId.make("grok-native-log-failure");

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

Lines changed: 120 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,14 @@ import {
5657
makeGrokAcpRuntime,
5758
resolveGrokAcpBaseModelId,
5859
} from "../acp/GrokAcpSupport.ts";
60+
import {
61+
extractXAiAskUserQuestions,
62+
extractXAiExitPlanMarkdown,
63+
makeXAiAskUserQuestionResponse,
64+
makeXAiExitPlanModeResponse,
65+
XAiAskUserQuestionRequest,
66+
XAiExitPlanModeRequest,
67+
} from "../acp/XAiAcpExtension.ts";
5968
import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
6069
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
6170

@@ -73,13 +82,17 @@ export interface GrokAdapterLiveOptions {
7382
readonly environment?: NodeJS.ProcessEnv;
7483
readonly nativeEventLogPath?: string;
7584
readonly nativeEventLogger?: EventNdjsonLogger;
76-
readonly instanceId?: typeof ProviderInstanceId.Type;
85+
readonly instanceId?: ProviderInstanceId;
7786
}
7887

7988
interface PendingApproval {
8089
readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
8190
}
8291

92+
interface PendingUserInput {
93+
readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
94+
}
95+
8396
interface GrokSessionContext {
8497
readonly threadId: ThreadId;
8598
readonly acpSessionId: string;
@@ -88,6 +101,7 @@ interface GrokSessionContext {
88101
readonly acp: AcpSessionRuntimeShape;
89102
notificationFiber: Fiber.Fiber<void, never> | undefined;
90103
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
104+
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
91105
turns: Array<{ id: TurnId; items: Array<unknown> }>;
92106
lastPlanFingerprint: string | undefined;
93107
activeTurnId: TurnId | undefined;
@@ -105,6 +119,16 @@ function settlePendingApprovalsAsCancelled(
105119
);
106120
}
107121

122+
function settlePendingUserInputsAsEmptyAnswers(
123+
pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,
124+
): Effect.Effect<void> {
125+
return Effect.forEach(
126+
Array.from(pendingUserInputs.values()),
127+
(pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
128+
{ discard: true },
129+
);
130+
}
131+
108132
function isRecord(value: unknown): value is Record<string, unknown> {
109133
return typeof value === "object" && value !== null && !Array.isArray(value);
110134
}
@@ -287,6 +311,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
287311
if (ctx.stopped) return;
288312
ctx.stopped = true;
289313
yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
314+
yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
290315
if (ctx.notificationFiber) {
291316
yield* Fiber.interrupt(ctx.notificationFiber);
292317
}
@@ -329,6 +354,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
329354
}
330355

331356
const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
357+
const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
332358
const sessionScope = yield* Scope.make("sequential");
333359
let sessionScopeTransferred = false;
334360
yield* Effect.addFinalizer(() =>
@@ -363,6 +389,82 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
363389
),
364390
);
365391
const started = yield* Effect.gen(function* () {
392+
yield* Effect.forEach(
393+
["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
394+
(method) =>
395+
acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) =>
396+
mapAcpCallbackFailure(
397+
Effect.gen(function* () {
398+
yield* logNative(input.threadId, method, params);
399+
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
400+
const runtimeRequestId = RuntimeRequestId.make(requestId);
401+
const answers = yield* Deferred.make<ProviderUserInputAnswers>();
402+
pendingUserInputs.set(requestId, { answers });
403+
yield* offerRuntimeEvent({
404+
type: "user-input.requested",
405+
...(yield* makeEventStamp()),
406+
provider: PROVIDER,
407+
threadId: input.threadId,
408+
turnId: sessions.get(input.threadId)?.activeTurnId,
409+
requestId: runtimeRequestId,
410+
payload: { questions: extractXAiAskUserQuestions(params) },
411+
raw: {
412+
source: "acp.grok.extension",
413+
method,
414+
payload: params,
415+
},
416+
});
417+
const resolved = yield* Deferred.await(answers);
418+
pendingUserInputs.delete(requestId);
419+
yield* offerRuntimeEvent({
420+
type: "user-input.resolved",
421+
...(yield* makeEventStamp()),
422+
provider: PROVIDER,
423+
threadId: input.threadId,
424+
turnId: sessions.get(input.threadId)?.activeTurnId,
425+
requestId: runtimeRequestId,
426+
payload: { answers: resolved },
427+
raw: {
428+
source: "acp.grok.extension",
429+
method,
430+
payload: params,
431+
},
432+
});
433+
return makeXAiAskUserQuestionResponse(resolved);
434+
}),
435+
),
436+
),
437+
{ discard: true },
438+
);
439+
yield* Effect.forEach(
440+
["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const,
441+
(method) =>
442+
acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) =>
443+
mapAcpCallbackFailure(
444+
Effect.gen(function* () {
445+
yield* logNative(input.threadId, method, params);
446+
const planMarkdown = extractXAiExitPlanMarkdown(params)?.trim();
447+
if (planMarkdown) {
448+
yield* offerRuntimeEvent({
449+
type: "turn.proposed.completed",
450+
...(yield* makeEventStamp()),
451+
provider: PROVIDER,
452+
threadId: input.threadId,
453+
turnId: sessions.get(input.threadId)?.activeTurnId,
454+
payload: { planMarkdown },
455+
raw: {
456+
source: "acp.grok.extension",
457+
method,
458+
payload: params,
459+
},
460+
});
461+
}
462+
return makeXAiExitPlanModeResponse();
463+
}),
464+
),
465+
),
466+
{ discard: true },
467+
);
366468
yield* acp.handleRequestPermission((params) =>
367469
mapAcpCallbackFailure(
368470
Effect.gen(function* () {
@@ -470,6 +572,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
470572
acp,
471573
notificationFiber: undefined,
472574
pendingApprovals,
575+
pendingUserInputs,
473576
turns: [],
474577
lastPlanFingerprint: undefined,
475578
activeTurnId: undefined,
@@ -733,6 +836,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
733836
Effect.gen(function* () {
734837
const ctx = yield* requireSession(threadId);
735838
yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
839+
yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
736840
yield* Effect.ignore(
737841
ctx.acp.cancel.pipe(
738842
Effect.mapError((error) =>
@@ -760,14 +864,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
760864
yield* Deferred.succeed(pending.decision, decision);
761865
});
762866

763-
const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (threadId, requestId) =>
867+
const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (
868+
threadId,
869+
requestId,
870+
answers,
871+
) =>
764872
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-
});
873+
const ctx = yield* requireSession(threadId);
874+
const pending = ctx.pendingUserInputs.get(requestId);
875+
if (!pending) {
876+
return yield* new ProviderAdapterRequestError({
877+
provider: PROVIDER,
878+
method: "_x.ai/ask_user_question",
879+
detail: `Unknown pending user-input request: ${requestId}`,
880+
});
881+
}
882+
yield* Deferred.succeed(pending.answers, answers);
771883
});
772884

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

0 commit comments

Comments
 (0)