Skip to content

Commit 38ea6d4

Browse files
feat(grok): add Grok CLI provider via ACP (#2809)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 0e4a435 commit 38ea6d4

40 files changed

Lines changed: 3673 additions & 22 deletions

apps/server/integration/OrchestrationEngineHarness.integration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { ProjectionCheckpointRepository } from "../src/persistence/Services/Proj
3535
import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
3636
import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
3737
import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
38+
import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
3839
import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
3940
import { ServerSettingsService } from "../src/serverSettings.ts";
4041
import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
@@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = (
293294
Layer.provide(AnalyticsService.layerTest),
294295
Layer.provide(providerEventLoggersLayer),
295296
);
297+
const providerRegistryLayer = makeProviderRegistryLayer();
296298

297299
const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer));
298300
const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive;
@@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = (
375377
const layer = Layer.empty.pipe(
376378
Layer.provideMerge(runtimeServicesLayer),
377379
Layer.provideMerge(orchestrationReactorLayer),
380+
Layer.provideMerge(providerRegistryLayer),
378381
Layer.provide(persistenceLayer),
379382
Layer.provideMerge(RepositoryIdentityResolverLive),
380383
Layer.provideMerge(ServerSettingsService.layerTest()),

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

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ 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;
25+
const permissionOptionIds = {
26+
allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
27+
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
28+
rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
29+
};
2430
const sessionId = "mock-session-1";
2531

2632
let currentModeId = "ask";
@@ -237,6 +243,21 @@ function modeState(): AcpSchema.SessionModeState {
237243
};
238244
}
239245

246+
const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
247+
{ modelId: "grok-build", name: "Grok Build" },
248+
{ modelId: "grok-mock-alt", name: "Grok Mock Alt" },
249+
];
250+
251+
function modelState(): AcpSchema.SessionModelState {
252+
const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)
253+
? currentModelId
254+
: "grok-build";
255+
return {
256+
currentModelId: modelId,
257+
availableModels: grokAcpModels,
258+
};
259+
}
260+
240261
const program = Effect.gen(function* () {
241262
const agent = yield* EffectAcpAgent.AcpAgent;
242263

@@ -257,6 +278,7 @@ const program = Effect.gen(function* () {
257278
Effect.succeed({
258279
sessionId,
259280
modes: modeState(),
281+
models: modelState(),
260282
configOptions: configOptions(),
261283
}),
262284
);
@@ -273,11 +295,28 @@ const program = Effect.gen(function* () {
273295
.pipe(
274296
Effect.as({
275297
modes: modeState(),
298+
models: modelState(),
276299
configOptions: configOptions(),
277300
}),
278301
),
279302
);
280303

304+
yield* agent.handleSetSessionModel((request) =>
305+
Effect.gen(function* () {
306+
if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {
307+
return yield* AcpError.AcpRequestError.invalidParams(
308+
`Unknown mock model id: ${request.modelId}`,
309+
{
310+
method: "session/set_model",
311+
params: request,
312+
},
313+
);
314+
}
315+
currentModelId = request.modelId;
316+
return {};
317+
}),
318+
);
319+
281320
yield* agent.handleSetSessionConfigOption((request) =>
282321
Effect.gen(function* () {
283322
if (exitOnSetConfigOption) {
@@ -419,9 +458,13 @@ const program = Effect.gen(function* () {
419458
],
420459
},
421460
options: [
422-
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
423-
{ optionId: "allow-always", name: "Allow always", kind: "allow_always" },
424-
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
461+
{ optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
462+
{
463+
optionId: permissionOptionIds.allowAlways,
464+
name: "Allow always",
465+
kind: "allow_always",
466+
},
467+
{ optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
425468
],
426469
});
427470

@@ -514,6 +557,43 @@ const program = Effect.gen(function* () {
514557
return { stopReason: "end_turn" };
515558
}
516559

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+
multiSelect: null,
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 (typeof result !== "object" || result === null || !("outcome" in result)) {
580+
throw new Error("Expected _x.ai/ask_user_question response outcome.");
581+
}
582+
if (result.outcome === "cancelled") {
583+
return { stopReason: "end_turn" };
584+
}
585+
if (
586+
result.outcome !== "accepted" ||
587+
!("answers" in result) ||
588+
typeof result.answers !== "object" ||
589+
result.answers === null
590+
) {
591+
throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
592+
}
593+
594+
return { stopReason: "end_turn" };
595+
}
596+
517597
yield* agent.client.sessionUpdate({
518598
sessionId: requestedSessionId,
519599
update: {

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
ProviderService,
4141
type ProviderServiceShape,
4242
} from "../../provider/Services/ProviderService.ts";
43+
import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
4344
import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
4445
import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
4546
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
@@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => {
142143
readonly baseDir?: string;
143144
readonly threadModelSelection?: ModelSelection;
144145
readonly sessionModelSwitch?: "unsupported" | "in-session";
146+
readonly requiresNewThreadForModelChange?: boolean;
145147
}) {
146148
const now = "2026-01-01T00:00:00.000Z";
147149
const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
@@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => {
280282
}),
281283
),
282284
);
285+
const providerSnapshots = [
286+
{
287+
instanceId: modelSelection.instanceId,
288+
...(input?.requiresNewThreadForModelChange === true
289+
? { requiresNewThreadForModelChange: true }
290+
: {}),
291+
},
292+
];
283293

284294
const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
285295
const service: ProviderServiceShape = {
@@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => {
335345
Layer.provideMerge(orchestrationLayer),
336346
Layer.provideMerge(projectionSnapshotLayer),
337347
Layer.provideMerge(Layer.succeed(ProviderService, service)),
348+
Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
338349
Layer.provideMerge(
339350
Layer.mock(GitWorkflowService)({
340351
renameBranch,
@@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => {
879890
});
880891
});
881892

893+
it("rejects changing models after start when the provider requires a new thread", async () => {
894+
const harness = await createHarness({ requiresNewThreadForModelChange: true });
895+
const now = "2026-01-01T00:00:00.000Z";
896+
897+
await Effect.runPromise(
898+
harness.engine.dispatch({
899+
type: "thread.turn.start",
900+
commandId: CommandId.make("cmd-turn-start-restricted-1"),
901+
threadId: ThreadId.make("thread-1"),
902+
message: {
903+
messageId: asMessageId("user-message-restricted-1"),
904+
role: "user",
905+
text: "first",
906+
attachments: [],
907+
},
908+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
909+
runtimeMode: "approval-required",
910+
createdAt: now,
911+
}),
912+
);
913+
914+
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
915+
916+
await Effect.runPromise(
917+
harness.engine.dispatch({
918+
type: "thread.turn.start",
919+
commandId: CommandId.make("cmd-turn-start-restricted-2"),
920+
threadId: ThreadId.make("thread-1"),
921+
message: {
922+
messageId: asMessageId("user-message-restricted-2"),
923+
role: "user",
924+
text: "second",
925+
attachments: [],
926+
},
927+
modelSelection: {
928+
instanceId: ProviderInstanceId.make("codex"),
929+
model: "gpt-5.1-codex",
930+
},
931+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
932+
runtimeMode: "approval-required",
933+
createdAt: now,
934+
}),
935+
);
936+
937+
await waitFor(async () => {
938+
const readModel = await harness.readModel();
939+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
940+
return (
941+
thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
942+
false
943+
);
944+
});
945+
946+
expect(harness.sendTurn).toHaveBeenCalledTimes(1);
947+
const readModel = await harness.readModel();
948+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
949+
expect(
950+
thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
951+
).toMatchObject({
952+
payload: {
953+
detail: expect.stringContaining("cannot switch models after the conversation has started"),
954+
},
955+
});
956+
});
957+
882958
it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => {
883959
const harness = await createHarness({
884960
threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
3131
import type { ProviderServiceError } from "../../provider/Errors.ts";
3232
import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
3333
import { ProviderService } from "../../provider/Services/ProviderService.ts";
34+
import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts";
3435
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3536
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
3637
import {
@@ -180,6 +181,7 @@ const make = Effect.gen(function* () {
180181
const orchestrationEngine = yield* OrchestrationEngineService;
181182
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
182183
const providerService = yield* ProviderService;
184+
const providerRegistry = yield* ProviderRegistry;
183185
const gitWorkflow = yield* GitWorkflowService;
184186
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
185187
const textGeneration = yield* TextGeneration;
@@ -305,6 +307,38 @@ const make = Effect.gen(function* () {
305307
.pipe(Effect.map(Option.getOrUndefined));
306308
});
307309

310+
const rejectStartedThreadModelChangeIfRequired = Effect.fnUntraced(function* (input: {
311+
readonly threadId: ThreadId;
312+
readonly currentModelSelection: ModelSelection;
313+
readonly requestedModelSelection: ModelSelection | undefined;
314+
}) {
315+
const requestedModelSelection = input.requestedModelSelection;
316+
if (
317+
requestedModelSelection === undefined ||
318+
(input.currentModelSelection.instanceId === requestedModelSelection.instanceId &&
319+
input.currentModelSelection.model === requestedModelSelection.model)
320+
) {
321+
return;
322+
}
323+
const providers = yield* providerRegistry.getProviders;
324+
const requiresNewThread =
325+
providers.find((snapshot) => snapshot.instanceId === input.currentModelSelection.instanceId)
326+
?.requiresNewThreadForModelChange === true ||
327+
providers.find((snapshot) => snapshot.instanceId === requestedModelSelection.instanceId)
328+
?.requiresNewThreadForModelChange === true;
329+
if (!requiresNewThread) {
330+
return;
331+
}
332+
return yield* new ProviderAdapterRequestError({
333+
provider: providerErrorLabelFromInstanceHint({
334+
instanceId: String(requestedModelSelection.instanceId),
335+
modelSelectionInstanceId: String(input.currentModelSelection.instanceId),
336+
}),
337+
method: "thread.turn.start",
338+
detail: `Thread '${input.threadId}' cannot switch models after the conversation has started. Start a new thread to use '${requestedModelSelection.model}'.`,
339+
});
340+
});
341+
308342
const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (
309343
threadId: ThreadId,
310344
createdAt: string,
@@ -384,6 +418,20 @@ const make = Effect.gen(function* () {
384418
});
385419
}
386420
const preferredProvider: ProviderDriverKind = desiredDriverKind;
421+
if (thread.session !== null) {
422+
yield* rejectStartedThreadModelChangeIfRequired({
423+
threadId,
424+
currentModelSelection:
425+
activeSession?.model !== undefined
426+
? {
427+
...thread.modelSelection,
428+
instanceId: currentInstanceId,
429+
model: activeSession.model,
430+
}
431+
: thread.modelSelection,
432+
requestedModelSelection,
433+
});
434+
}
387435
if (
388436
thread.session !== null &&
389437
requestedModelSelection !== undefined &&

0 commit comments

Comments
 (0)