authorgravatar for 25470423+Jaaneek@users.noreply.github.comMilosz Jankiewicz <25470423+Jaaneek@users.noreply.github.com> 2026-06-09 17:28:40+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-09 09:28:40-07:00
log38ea6d483ebf0a8c76f7105824f79489871057e5
tree02c12fd27d669a9488c0252e5faf892cbe7839a0
parent0e4a43519fe4fa85ce60aa8d6d53c067e56d2fca
signature Signed by PGP key B5690EEEBB952194

feat(grok): add Grok CLI provider via ACP (#2809)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

40 files changed, 3673 insertions(+), 22 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+3
...@@ -35,6 +35,7 @@ import { ProjectionCheckpointRepository } from "../src/persistence/Services/Proj...@@ -35,6 +35,7 @@ import { ProjectionCheckpointRepository } from "../src/persistence/Services/Proj
35import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";35import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
36import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";36import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
37import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";37import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
38import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
38import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";39import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
39import { ServerSettingsService } from "../src/serverSettings.ts";40import { ServerSettingsService } from "../src/serverSettings.ts";
40import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";41import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
...@@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = (
293 Layer.provide(AnalyticsService.layerTest),294 Layer.provide(AnalyticsService.layerTest),
294 Layer.provide(providerEventLoggersLayer),295 Layer.provide(providerEventLoggersLayer),
295 );296 );
297 const providerRegistryLayer = makeProviderRegistryLayer();
296298
297 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer));299 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer));
298 const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive;300 const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive;
...@@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = (
375 const layer = Layer.empty.pipe(377 const layer = Layer.empty.pipe(
376 Layer.provideMerge(runtimeServicesLayer),378 Layer.provideMerge(runtimeServicesLayer),
377 Layer.provideMerge(orchestrationReactorLayer),379 Layer.provideMerge(orchestrationReactorLayer),
380 Layer.provideMerge(providerRegistryLayer),
378 Layer.provide(persistenceLayer),381 Layer.provide(persistenceLayer),
379 Layer.provideMerge(RepositoryIdentityResolverLive),382 Layer.provideMerge(RepositoryIdentityResolverLive),
380 Layer.provideMerge(ServerSettingsService.layerTest()),383 Layer.provideMerge(ServerSettingsService.layerTest()),
apps/server/scripts/acp-mock-agent.ts+83-3
...@@ -18,9 +18,15 @@ const emitInterleavedAssistantToolCalls =...@@ -18,9 +18,15 @@ const emitInterleavedAssistantToolCalls =
18 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";18 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
19const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";19const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
20const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";20const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
21const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
21const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";22const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
22const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";23const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
23const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;24const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
25const 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};
24const sessionId = "mock-session-1";30const sessionId = "mock-session-1";
2531
26let currentModeId = "ask";32let currentModeId = "ask";
...@@ -237,6 +243,21 @@ function modeState(): AcpSchema.SessionModeState {...@@ -237,6 +243,21 @@ function modeState(): AcpSchema.SessionModeState {
237 };243 };
238}244}
239245
246const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
247 { modelId: "grok-build", name: "Grok Build" },
248 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
249];
250
251function 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
240const program = Effect.gen(function* () {261const program = Effect.gen(function* () {
241 const agent = yield* EffectAcpAgent.AcpAgent;262 const agent = yield* EffectAcpAgent.AcpAgent;
242263
...@@ -257,6 +278,7 @@ const program = Effect.gen(function* () {...@@ -257,6 +278,7 @@ const program = Effect.gen(function* () {
257 Effect.succeed({278 Effect.succeed({
258 sessionId,279 sessionId,
259 modes: modeState(),280 modes: modeState(),
281 models: modelState(),
260 configOptions: configOptions(),282 configOptions: configOptions(),
261 }),283 }),
262 );284 );
...@@ -273,11 +295,28 @@ const program = Effect.gen(function* () {...@@ -273,11 +295,28 @@ const program = Effect.gen(function* () {
273 .pipe(295 .pipe(
274 Effect.as({296 Effect.as({
275 modes: modeState(),297 modes: modeState(),
298 models: modelState(),
276 configOptions: configOptions(),299 configOptions: configOptions(),
277 }),300 }),
278 ),301 ),
279 );302 );
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
281 yield* agent.handleSetSessionConfigOption((request) =>320 yield* agent.handleSetSessionConfigOption((request) =>
282 Effect.gen(function* () {321 Effect.gen(function* () {
283 if (exitOnSetConfigOption) {322 if (exitOnSetConfigOption) {
...@@ -419,9 +458,13 @@ const program = Effect.gen(function* () {...@@ -419,9 +458,13 @@ const program = Effect.gen(function* () {
419 ],458 ],
420 },459 },
421 options: [460 options: [
422 { optionId: "allow-once", name: "Allow once", kind: "allow_once" },461 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
423 { optionId: "allow-always", name: "Allow always", kind: "allow_always" },462 {
424 { optionId: "reject-once", name: "Reject", kind: "reject_once" },463 optionId: permissionOptionIds.allowAlways,
464 name: "Allow always",
465 kind: "allow_always",
466 },
467 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
425 ],468 ],
426 });469 });
427470
...@@ -514,6 +557,43 @@ const program = Effect.gen(function* () {...@@ -514,6 +557,43 @@ const program = Effect.gen(function* () {
514 return { stopReason: "end_turn" };557 return { stopReason: "end_turn" };
515 }558 }
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
517 yield* agent.client.sessionUpdate({597 yield* agent.client.sessionUpdate({
518 sessionId: requestedSessionId,598 sessionId: requestedSessionId,
519 update: {599 update: {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+76
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 ProviderService,40 ProviderService,
41 type ProviderServiceShape,41 type ProviderServiceShape,
42} from "../../provider/Services/ProviderService.ts";42} from "../../provider/Services/ProviderService.ts";
43import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
43import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";44import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
44import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";45import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
45import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";46import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
...@@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => {...@@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => {
142 readonly baseDir?: string;143 readonly baseDir?: string;
143 readonly threadModelSelection?: ModelSelection;144 readonly threadModelSelection?: ModelSelection;
144 readonly sessionModelSwitch?: "unsupported" | "in-session";145 readonly sessionModelSwitch?: "unsupported" | "in-session";
146 readonly requiresNewThreadForModelChange?: boolean;
145 }) {147 }) {
146 const now = "2026-01-01T00:00:00.000Z";148 const now = "2026-01-01T00:00:00.000Z";
147 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));149 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
...@@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => {...@@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => {
280 }),282 }),
281 ),283 ),
282 );284 );
285 const providerSnapshots = [
286 {
287 instanceId: modelSelection.instanceId,
288 ...(input?.requiresNewThreadForModelChange === true
289 ? { requiresNewThreadForModelChange: true }
290 : {}),
291 },
292 ];
283293
284 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;294 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
285 const service: ProviderServiceShape = {295 const service: ProviderServiceShape = {
...@@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => {...@@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => {
335 Layer.provideMerge(orchestrationLayer),345 Layer.provideMerge(orchestrationLayer),
336 Layer.provideMerge(projectionSnapshotLayer),346 Layer.provideMerge(projectionSnapshotLayer),
337 Layer.provideMerge(Layer.succeed(ProviderService, service)),347 Layer.provideMerge(Layer.succeed(ProviderService, service)),
348 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
338 Layer.provideMerge(349 Layer.provideMerge(
339 Layer.mock(GitWorkflowService)({350 Layer.mock(GitWorkflowService)({
340 renameBranch,351 renameBranch,
...@@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => {...@@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => {
879 });890 });
880 });891 });
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
882 it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => {958 it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => {
883 const harness = await createHarness({959 const harness = await createHarness({
884 threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },960 threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+48
...@@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts";...@@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
31import type { ProviderServiceError } from "../../provider/Errors.ts";31import type { ProviderServiceError } from "../../provider/Errors.ts";
32import { TextGeneration } from "../../textGeneration/TextGeneration.ts";32import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
33import { ProviderService } from "../../provider/Services/ProviderService.ts";33import { ProviderService } from "../../provider/Services/ProviderService.ts";
34import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts";
34import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";35import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
35import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";36import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
36import {37import {
...@@ -180,6 +181,7 @@ const make = Effect.gen(function* () {...@@ -180,6 +181,7 @@ const make = Effect.gen(function* () {
180 const orchestrationEngine = yield* OrchestrationEngineService;181 const orchestrationEngine = yield* OrchestrationEngineService;
181 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;182 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
182 const providerService = yield* ProviderService;183 const providerService = yield* ProviderService;
184 const providerRegistry = yield* ProviderRegistry;
183 const gitWorkflow = yield* GitWorkflowService;185 const gitWorkflow = yield* GitWorkflowService;
184 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;186 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
185 const textGeneration = yield* TextGeneration;187 const textGeneration = yield* TextGeneration;
...@@ -305,6 +307,38 @@ const make = Effect.gen(function* () {...@@ -305,6 +307,38 @@ const make = Effect.gen(function* () {
305 .pipe(Effect.map(Option.getOrUndefined));307 .pipe(Effect.map(Option.getOrUndefined));
306 });308 });
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
308 const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (342 const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (
309 threadId: ThreadId,343 threadId: ThreadId,
310 createdAt: string,344 createdAt: string,
...@@ -384,6 +418,20 @@ const make = Effect.gen(function* () {...@@ -384,6 +418,20 @@ const make = Effect.gen(function* () {
384 });418 });
385 }419 }
386 const preferredProvider: ProviderDriverKind = desiredDriverKind;420 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 }
387 if (435 if (
388 thread.session !== null &&436 thread.session !== null &&
389 requestedModelSelection !== undefined &&437 requestedModelSelection !== undefined &&
apps/server/src/provider/Drivers/GrokDriver.ts created+153
...@@ -0,0 +1,153 @@
1import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import { HttpClient } from "effect/unstable/http";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import { ServerConfig } from "../../config.ts";
13import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
14import { ProviderDriverError } from "../Errors.ts";
15import { makeGrokAdapter } from "../Layers/GrokAdapter.ts";
16import {
17 buildInitialGrokProviderSnapshot,
18 checkGrokProviderStatus,
19 enrichGrokSnapshot,
20} from "../Layers/GrokProvider.ts";
21import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
22import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
23import {
24 defaultProviderContinuationIdentity,
25 type ProviderDriver,
26 type ProviderInstance,
27} from "../ProviderDriver.ts";
28import type { ServerProviderDraft } from "../providerSnapshot.ts";
29import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
30import {
31 makeManualOnlyProviderMaintenanceCapabilities,
32 makeStaticProviderMaintenanceResolver,
33 resolveProviderMaintenanceCapabilitiesEffect,
34} from "../providerMaintenance.ts";
35const decodeGrokSettings = Schema.decodeSync(GrokSettings);
36
37const DRIVER_KIND = ProviderDriverKind.make("grok");
38const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
39const UPDATE = makeStaticProviderMaintenanceResolver(
40 makeManualOnlyProviderMaintenanceCapabilities({
41 provider: DRIVER_KIND,
42 packageName: null,
43 }),
44);
45
46export type GrokDriverEnv =
47 | ChildProcessSpawner.ChildProcessSpawner
48 | Crypto.Crypto
49 | FileSystem.FileSystem
50 | HttpClient.HttpClient
51 | Path.Path
52 | ProviderEventLoggers
53 | ServerConfig;
54
55const withInstanceIdentity =
56 (input: {
57 readonly instanceId: ProviderInstance["instanceId"];
58 readonly displayName: string | undefined;
59 readonly accentColor: string | undefined;
60 readonly continuationGroupKey: string;
61 }) =>
62 (snapshot: ServerProviderDraft): ServerProvider => ({
63 ...snapshot,
64 instanceId: input.instanceId,
65 driver: DRIVER_KIND,
66 ...(input.displayName ? { displayName: input.displayName } : {}),
67 ...(input.accentColor ? { accentColor: input.accentColor } : {}),
68 continuation: { groupKey: input.continuationGroupKey },
69 });
70
71export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
72 driverKind: DRIVER_KIND,
73 metadata: {
74 displayName: "Grok",
75 supportsMultipleInstances: true,
76 },
77 configSchema: GrokSettings,
78 defaultConfig: (): GrokSettings => decodeGrokSettings({}),
79 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
80 Effect.gen(function* () {
81 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
82 const httpClient = yield* HttpClient.HttpClient;
83 const eventLoggers = yield* ProviderEventLoggers;
84 const processEnv = mergeProviderInstanceEnvironment(environment);
85 const continuationIdentity = defaultProviderContinuationIdentity({
86 driverKind: DRIVER_KIND,
87 instanceId,
88 });
89 const stampIdentity = withInstanceIdentity({
90 instanceId,
91 displayName,
92 accentColor,
93 continuationGroupKey: continuationIdentity.continuationKey,
94 });
95 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;
96 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
97 binaryPath: effectiveConfig.binaryPath,
98 env: processEnv,
99 });
100
101 const adapter = yield* makeGrokAdapter(effectiveConfig, {
102 environment: processEnv,
103 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
104 instanceId,
105 });
106 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
107
108 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe(
109 Effect.map(stampIdentity),
110 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
111 );
112
113 const snapshot = yield* makeManagedServerProvider<GrokSettings>({
114 maintenanceCapabilities,
115 getSettings: Effect.succeed(effectiveConfig),
116 streamSettings: Stream.never,
117 haveSettingsChanged: () => false,
118 initialSnapshot: (settings) =>
119 buildInitialGrokProviderSnapshot(settings).pipe(Effect.map(stampIdentity)),
120 checkProvider,
121 enrichSnapshot: ({ snapshot: currentSnapshot, publishSnapshot }) =>
122 enrichGrokSnapshot({
123 snapshot: currentSnapshot,
124 maintenanceCapabilities,
125 publishSnapshot,
126 httpClient,
127 }),
128 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
129 }).pipe(
130 Effect.mapError(
131 (cause) =>
132 new ProviderDriverError({
133 driver: DRIVER_KIND,
134 instanceId,
135 detail: `Failed to build Grok snapshot: ${cause.message ?? String(cause)}`,
136 cause,
137 }),
138 ),
139 );
140
141 return {
142 instanceId,
143 driverKind: DRIVER_KIND,
144 continuationIdentity,
145 displayName,
146 accentColor,
147 enabled,
148 snapshot,
149 adapter,
150 textGeneration,
151 } satisfies ProviderInstance;
152 }),
153};
apps/server/src/provider/Layers/GrokAdapter.test.ts created+376
...@@ -0,0 +1,376 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as path from "node:path";
3import * as os from "node:os";
4import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
5import { fileURLToPath } from "node:url";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";
14import * as Stream from "effect/Stream";
15
16import {
17 ApprovalRequestId,
18 GrokSettings,
19 ProviderDriverKind,
20 ThreadId,
21 ProviderInstanceId,
22 type ProviderRuntimeEvent,
23} from "@t3tools/contracts";
24
25import { ServerConfig } from "../../config.ts";
26import { makeGrokAdapter } from "./GrokAdapter.ts";
27const decodeGrokSettings = Schema.decodeSync(GrokSettings);
28
29const __dirname = path.dirname(fileURLToPath(import.meta.url));
30const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
31const mockAgentCommand = process.execPath;
32
33async function makeMockGrokWrapper(extraEnv?: Record<string, string>) {
34 const dir = await mkdtemp(path.join(os.tmpdir(), "grok-acp-mock-"));
35 const wrapperPath = path.join(dir, "fake-grok.sh");
36 const envExports = Object.entries(extraEnv ?? {})
37 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
38 .join("\n");
39 const script = `#!/bin/sh
40${envExports}
41exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@"
42`;
43 await writeFile(wrapperPath, script, "utf8");
44 await chmod(wrapperPath, 0o755);
45 return wrapperPath;
46}
47
48async function waitForFileContent(filePath: string, attempts = 40): Promise<string> {
49 const readAttempt = async (remainingAttempts: number): Promise<string> => {
50 if (remainingAttempts <= 0) {
51 throw new Error(`Timed out waiting for file content at ${filePath}`);
52 }
53 try {
54 const raw = await readFile(filePath, "utf8");
55 if (raw.trim().length > 0) {
56 return raw;
57 }
58 } catch {}
59 await Effect.runPromise(Effect.sleep("25 millis"));
60 return readAttempt(remainingAttempts - 1);
61 };
62 return readAttempt(attempts);
63}
64
65async function readJsonLines(filePath: string) {
66 const raw = await readFile(filePath, "utf8");
67 return raw
68 .split("\n")
69 .map((line) => line.trim())
70 .filter((line) => line.length > 0)
71 .map((line) => JSON.parse(line) as Record<string, unknown>);
72}
73
74const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
75 prefix: "t3code-grok-adapter-test-",
76}).pipe(Layer.provideMerge(NodeServices.layer));
77
78const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) =>
79 makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie);
80
81it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
82 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
83 Effect.gen(function* () {
84 const threadId = ThreadId.make("grok-mock-thread");
85 const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
86 const adapter = yield* makeTestAdapter(wrapperPath);
87
88 const runtimeEvents: ProviderRuntimeEvent[] = [];
89 const turnCompleted = yield* Deferred.make<void>();
90 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
91 Effect.sync(() => {
92 runtimeEvents.push(event);
93 }).pipe(
94 Effect.andThen(
95 event.type === "turn.completed"
96 ? Deferred.succeed(turnCompleted, undefined)
97 : Effect.void,
98 ),
99 ),
100 ).pipe(Effect.forkChild);
101
102 const session = yield* adapter.startSession({
103 threadId,
104 provider: ProviderDriverKind.make("grok"),
105 cwd: process.cwd(),
106 runtimeMode: "full-access",
107 modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-mock-alt" },
108 });
109
110 assert.equal(session.provider, "grok");
111 assert.equal(session.model, "grok-mock-alt");
112 assert.deepStrictEqual(session.resumeCursor, {
113 schemaVersion: 1,
114 sessionId: "mock-session-1",
115 });
116
117 yield* adapter.sendTurn({
118 threadId,
119 input: "hello grok",
120 attachments: [],
121 });
122
123 yield* Deferred.await(turnCompleted);
124 yield* Fiber.interrupt(runtimeEventsFiber);
125 const types = runtimeEvents.map((e) => e.type);
126
127 assert.includeMembers(types, [
128 "session.started",
129 "session.state.changed",
130 "thread.started",
131 "turn.started",
132 "item.started",
133 "content.delta",
134 "turn.completed",
135 ] as const);
136
137 const delta = runtimeEvents.find((e) => e.type === "content.delta");
138 assert.isDefined(delta);
139 if (delta?.type === "content.delta") {
140 assert.equal(delta.payload.delta, "hello from mock");
141 }
142
143 yield* adapter.stopSession(threadId);
144 }),
145 );
146
147 it.effect("closes the ACP child process when a session stops", () =>
148 Effect.gen(function* () {
149 const threadId = ThreadId.make("grok-stop-session-close");
150 const tempDir = yield* Effect.promise(() =>
151 mkdtemp(path.join(os.tmpdir(), "grok-adapter-exit-log-")),
152 );
153 const exitLogPath = path.join(tempDir, "exit.log");
154
155 const wrapperPath = yield* Effect.promise(() =>
156 makeMockGrokWrapper({
157 T3_ACP_EXIT_LOG_PATH: exitLogPath,
158 }),
159 );
160 const adapter = yield* makeTestAdapter(wrapperPath);
161
162 yield* adapter.startSession({
163 threadId,
164 provider: ProviderDriverKind.make("grok"),
165 cwd: process.cwd(),
166 runtimeMode: "full-access",
167 modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
168 });
169
170 yield* adapter.stopSession(threadId);
171
172 const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath));
173 assert.include(exitLog, "SIGTERM");
174 }),
175 );
176
177 it.effect("rejects startSession when provider mismatches", () =>
178 Effect.gen(function* () {
179 const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
180 const adapter = yield* makeTestAdapter(wrapperPath);
181 const threadId = ThreadId.make("grok-provider-mismatch");
182
183 const error = yield* Effect.flip(
184 adapter.startSession({
185 threadId,
186 provider: ProviderDriverKind.make("cursor"),
187 cwd: process.cwd(),
188 runtimeMode: "full-access",
189 modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
190 }),
191 );
192
193 assert.equal(error._tag, "ProviderAdapterValidationError");
194 }),
195 );
196
197 it.effect("rejects sendTurn with empty input and no attachments", () =>
198 Effect.gen(function* () {
199 const threadId = ThreadId.make("grok-empty-turn");
200
201 const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
202 const adapter = yield* makeTestAdapter(wrapperPath);
203
204 yield* adapter.startSession({
205 threadId,
206 provider: ProviderDriverKind.make("grok"),
207 cwd: process.cwd(),
208 runtimeMode: "full-access",
209 modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
210 });
211
212 const error = yield* Effect.flip(
213 adapter.sendTurn({
214 threadId,
215 input: " ",
216 attachments: [],
217 }),
218 );
219
220 assert.equal(error._tag, "ProviderAdapterValidationError");
221
222 yield* adapter.stopSession(threadId);
223 }),
224 );
225
226 it.effect("responds to ACP approvals using provider-supplied option ids", () =>
227 Effect.gen(function* () {
228 const threadId = ThreadId.make("grok-custom-approval-option-id");
229 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "grok-acp-")));
230 const requestLogPath = path.join(tempDir, "requests.ndjson");
231 const wrapperPath = yield* Effect.promise(() =>
232 makeMockGrokWrapper({
233 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
234 T3_ACP_EMIT_TOOL_CALLS: "1",
235 T3_ACP_ALLOW_ONCE_OPTION_ID: "agent-defined-approval-id",
236 }),
237 );
238 const adapter = yield* makeTestAdapter(wrapperPath);
239 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
240 event.type === "request.opened"
241 ? adapter.respondToRequest(
242 threadId,
243 ApprovalRequestId.make(String(event.requestId)),
244 "accept",
245 )
246 : Effect.void,
247 ).pipe(Effect.forkChild);
248
249 yield* adapter.startSession({
250 threadId,
251 provider: ProviderDriverKind.make("grok"),
252 cwd: process.cwd(),
253 runtimeMode: "approval-required",
254 });
255 yield* adapter.sendTurn({ threadId, input: "approve this", attachments: [] });
256
257 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
258 assert.isTrue(
259 requests.some(
260 (entry) =>
261 !("method" in entry) &&
262 typeof entry.result === "object" &&
263 entry.result !== null &&
264 "outcome" in entry.result &&
265 typeof entry.result.outcome === "object" &&
266 entry.result.outcome !== null &&
267 "optionId" in entry.result.outcome &&
268 entry.result.outcome.optionId === "agent-defined-approval-id",
269 ),
270 );
271
272 yield* Fiber.interrupt(eventsFiber);
273 yield* adapter.stopSession(threadId);
274 }),
275 );
276
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("continues streaming events when native notification logging fails", () =>
339 Effect.gen(function* () {
340 const threadId = ThreadId.make("grok-native-log-failure");
341 const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
342 const adapter = yield* makeTestAdapter(wrapperPath, {
343 nativeEventLogger: {
344 filePath: "memory://grok-native-events",
345 write: (record: unknown) =>
346 typeof record === "object" &&
347 record !== null &&
348 "event" in record &&
349 typeof record.event === "object" &&
350 record.event !== null &&
351 "kind" in record.event &&
352 record.event.kind === "notification"
353 ? Effect.die(new Error("native log write failed"))
354 : Effect.void,
355 close: () => Effect.void,
356 },
357 });
358 const contentDelta = yield* Deferred.make<void>();
359 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
360 event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void,
361 ).pipe(Effect.forkChild);
362
363 yield* adapter.startSession({
364 threadId,
365 provider: ProviderDriverKind.make("grok"),
366 cwd: process.cwd(),
367 runtimeMode: "full-access",
368 });
369 yield* adapter.sendTurn({ threadId, input: "keep streaming", attachments: [] });
370 yield* Deferred.await(contentDelta);
371
372 yield* Fiber.interrupt(eventsFiber);
373 yield* adapter.stopSession(threadId);
374 }),
375 );
376});
apps/server/src/provider/Layers/GrokAdapter.ts created+934
...@@ -0,0 +1,934 @@
1import {
2 ApprovalRequestId,
3 type GrokSettings,
4 EventId,
5 type ProviderApprovalDecision,
6 type ProviderRuntimeEvent,
7 type ProviderSession,
8 type ProviderUserInputAnswers,
9 ProviderDriverKind,
10 ProviderInstanceId,
11 RuntimeRequestId,
12 type ThreadId,
13 TurnId,
14} from "@t3tools/contracts";
15import * as Crypto from "effect/Crypto";
16import * as DateTime from "effect/DateTime";
17import * as Deferred from "effect/Deferred";
18import * as Effect from "effect/Effect";
19import * as Exit from "effect/Exit";
20import * as Fiber from "effect/Fiber";
21import * as FileSystem from "effect/FileSystem";
22import * as Option from "effect/Option";
23import * as Path from "effect/Path";
24import * as PubSub from "effect/PubSub";
25import * as Schema from "effect/Schema";
26import * as Scope from "effect/Scope";
27import * as Semaphore from "effect/Semaphore";
28import * as Stream from "effect/Stream";
29import * as SynchronizedRef from "effect/SynchronizedRef";
30import { ChildProcessSpawner } from "effect/unstable/process";
31import * as EffectAcpErrors from "effect-acp/errors";
32import type * as EffectAcpSchema from "effect-acp/schema";
33
34import { resolveAttachmentPath } from "../../attachmentStore.ts";
35import { ServerConfig } from "../../config.ts";
36import {
37 ProviderAdapterProcessError,
38 ProviderAdapterRequestError,
39 ProviderAdapterSessionNotFoundError,
40 ProviderAdapterValidationError,
41} from "../Errors.ts";
42import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
43import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts";
44import {
45 makeAcpAssistantItemEvent,
46 makeAcpContentDeltaEvent,
47 makeAcpPlanUpdatedEvent,
48 makeAcpRequestOpenedEvent,
49 makeAcpRequestResolvedEvent,
50 makeAcpToolCallEvent,
51} from "../acp/AcpCoreRuntimeEvents.ts";
52import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts";
53import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
54import {
55 applyGrokAcpModelSelection,
56 currentGrokModelIdFromSessionSetup,
57 makeGrokAcpRuntime,
58 resolveGrokAcpBaseModelId,
59} from "../acp/GrokAcpSupport.ts";
60import {
61 extractXAiAskUserQuestions,
62 makeXAiAskUserQuestionCancelledResponse,
63 makeXAiAskUserQuestionResponse,
64 XAiAskUserQuestionRequest,
65} from "../acp/XAiAcpExtension.ts";
66import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
67import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
68
69const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);
70
71const PROVIDER = ProviderDriverKind.make("grok");
72const GROK_RESUME_VERSION = 1 as const;
73
74function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
75 const result = encodeUnknownJsonStringExit(input);
76 return Exit.isSuccess(result) ? result.value : undefined;
77}
78
79export interface GrokAdapterLiveOptions {
80 readonly environment?: NodeJS.ProcessEnv;
81 readonly nativeEventLogPath?: string;
82 readonly nativeEventLogger?: EventNdjsonLogger;
83 readonly instanceId?: ProviderInstanceId;
84}
85
86interface PendingApproval {
87 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
88}
89
90type PendingUserInputResolution =
91 | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers }
92 | { readonly _tag: "cancelled" };
93
94interface PendingUserInput {
95 readonly resolution: Deferred.Deferred<PendingUserInputResolution>;
96}
97
98interface GrokSessionContext {
99 readonly threadId: ThreadId;
100 readonly acpSessionId: string;
101 session: ProviderSession;
102 readonly scope: Scope.Closeable;
103 readonly acp: AcpSessionRuntimeShape;
104 notificationFiber: Fiber.Fiber<void, never> | undefined;
105 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
106 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
107 turns: Array<{ id: TurnId; items: Array<unknown> }>;
108 lastPlanFingerprint: string | undefined;
109 activeTurnId: TurnId | undefined;
110 currentModelId: string | undefined;
111 stopped: boolean;
112}
113
114function settlePendingApprovalsAsCancelled(
115 pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>,
116): Effect.Effect<void> {
117 return Effect.forEach(
118 Array.from(pendingApprovals.values()),
119 (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore),
120 { discard: true },
121 );
122}
123
124function settlePendingUserInputsAsCancelled(
125 pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,
126): Effect.Effect<void> {
127 return Effect.forEach(
128 Array.from(pendingUserInputs.values()),
129 (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore),
130 { discard: true },
131 );
132}
133
134function isRecord(value: unknown): value is Record<string, unknown> {
135 return typeof value === "object" && value !== null && !Array.isArray(value);
136}
137
138function parseGrokResume(raw: unknown): { sessionId: string } | undefined {
139 if (!isRecord(raw)) return undefined;
140 if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined;
141 if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined;
142 return { sessionId: raw.sessionId.trim() };
143}
144
145function selectPermissionOptionId(
146 request: EffectAcpSchema.RequestPermissionRequest,
147 decision: Exclude<ProviderApprovalDecision, "cancel">,
148): string | undefined {
149 const kind =
150 decision === "acceptForSession"
151 ? "allow_always"
152 : decision === "accept"
153 ? "allow_once"
154 : "reject_once";
155 const option = request.options.find((entry) => entry.kind === kind);
156 return option?.optionId.trim() || undefined;
157}
158
159function selectAutoApprovedPermissionOption(
160 request: EffectAcpSchema.RequestPermissionRequest,
161): string | undefined {
162 return (
163 selectPermissionOptionId(request, "acceptForSession") ??
164 selectPermissionOptionId(request, "accept")
165 );
166}
167
168export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) {
169 return Effect.gen(function* () {
170 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok");
171 const fileSystem = yield* FileSystem.FileSystem;
172 const path = yield* Path.Path;
173 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
174 const serverConfig = yield* Effect.service(ServerConfig);
175 const crypto = yield* Crypto.Crypto;
176 const nativeEventLogger =
177 options?.nativeEventLogger ??
178 (options?.nativeEventLogPath !== undefined
179 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" })
180 : undefined);
181 const managedNativeEventLogger =
182 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
183 const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory();
184
185 const sessions = new Map<ThreadId, GrokSessionContext>();
186 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
187 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
188
189 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
190 const randomUUIDv4 = crypto.randomUUIDv4.pipe(
191 Effect.mapError(
192 (cause) =>
193 new ProviderAdapterRequestError({
194 provider: PROVIDER,
195 method: "crypto/randomUUIDv4",
196 detail: "Failed to generate Grok runtime identifier.",
197 cause,
198 }),
199 ),
200 );
201 const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id));
202 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
203 const mapAcpCallbackFailure = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
204 effect.pipe(
205 Effect.mapError(
206 (cause) =>
207 new EffectAcpErrors.AcpTransportError({
208 detail: "Failed to process Grok ACP callback.",
209 cause,
210 }),
211 ),
212 );
213
214 const offerRuntimeEvent = (event: ProviderRuntimeEvent) =>
215 PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid);
216
217 const getThreadSemaphore = (threadId: string) =>
218 SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
219 const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr(
220 current.get(threadId),
221 );
222 return Option.match(existing, {
223 onNone: () =>
224 Semaphore.make(1).pipe(
225 Effect.map((semaphore) => {
226 const next = new Map(current);
227 next.set(threadId, semaphore);
228 return [semaphore, next] as const;
229 }),
230 ),
231 onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
232 });
233 });
234
235 const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) =>
236 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
237
238 const logNative = (threadId: ThreadId, method: string, payload: unknown) =>
239 Effect.gen(function* () {
240 if (!nativeEventLogger) return;
241 const observedAt = yield* nowIso;
242 yield* nativeEventLogger.write(
243 {
244 observedAt,
245 event: {
246 id: yield* randomUUIDv4,
247 kind: "notification",
248 provider: PROVIDER,
249 createdAt: observedAt,
250 method,
251 threadId,
252 payload,
253 },
254 },
255 threadId,
256 );
257 }).pipe(
258 Effect.catchCause((cause) =>
259 Effect.logWarning("Failed to write native Grok notification log.", {
260 cause,
261 threadId,
262 method,
263 }),
264 ),
265 );
266
267 const emitPlanUpdate = (
268 ctx: GrokSessionContext,
269 payload: {
270 readonly explanation?: string | null;
271 readonly plan: ReadonlyArray<{
272 readonly step: string;
273 readonly status: "pending" | "inProgress" | "completed";
274 }>;
275 },
276 rawPayload: unknown,
277 method: string,
278 ) =>
279 Effect.gen(function* () {
280 const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`;
281 if (ctx.lastPlanFingerprint === fingerprint) {
282 return;
283 }
284 ctx.lastPlanFingerprint = fingerprint;
285 yield* offerRuntimeEvent(
286 makeAcpPlanUpdatedEvent({
287 stamp: yield* makeEventStamp(),
288 provider: PROVIDER,
289 threadId: ctx.threadId,
290 turnId: ctx.activeTurnId,
291 payload,
292 source: "acp.jsonrpc",
293 method,
294 rawPayload,
295 }),
296 );
297 });
298
299 const requireSession = (
300 threadId: ThreadId,
301 ): Effect.Effect<GrokSessionContext, ProviderAdapterSessionNotFoundError> => {
302 const ctx = sessions.get(threadId);
303 if (!ctx || ctx.stopped) {
304 return Effect.fail(
305 new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }),
306 );
307 }
308 return Effect.succeed(ctx);
309 };
310
311 const stopSessionInternal = (ctx: GrokSessionContext) =>
312 Effect.gen(function* () {
313 if (ctx.stopped) return;
314 ctx.stopped = true;
315 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
316 yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs);
317 if (ctx.notificationFiber) {
318 yield* Fiber.interrupt(ctx.notificationFiber);
319 }
320 yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
321 sessions.delete(ctx.threadId);
322 yield* offerRuntimeEvent({
323 type: "session.exited",
324 ...(yield* makeEventStamp()),
325 provider: PROVIDER,
326 threadId: ctx.threadId,
327 payload: { exitKind: "graceful" },
328 });
329 });
330
331 const startSession: GrokAdapterShape["startSession"] = (input) =>
332 withThreadLock(
333 input.threadId,
334 Effect.gen(function* () {
335 if (input.provider !== undefined && input.provider !== PROVIDER) {
336 return yield* new ProviderAdapterValidationError({
337 provider: PROVIDER,
338 operation: "startSession",
339 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
340 });
341 }
342 if (!input.cwd?.trim()) {
343 return yield* new ProviderAdapterValidationError({
344 provider: PROVIDER,
345 operation: "startSession",
346 issue: "cwd is required and must be non-empty.",
347 });
348 }
349
350 const cwd = path.resolve(input.cwd.trim());
351 const grokModelSelection =
352 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
353 const existing = sessions.get(input.threadId);
354 if (existing && !existing.stopped) {
355 yield* stopSessionInternal(existing);
356 }
357
358 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
359 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
360 const sessionScope = yield* Scope.make("sequential");
361 let sessionScopeTransferred = false;
362 yield* Effect.addFinalizer(() =>
363 sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
364 );
365
366 const resumeSessionId = parseGrokResume(input.resumeCursor)?.sessionId;
367 const acpNativeLoggers = makeAcpNativeLoggers({
368 nativeEventLogger,
369 provider: PROVIDER,
370 threadId: input.threadId,
371 });
372
373 const acp = yield* makeGrokAcpRuntime({
374 grokSettings,
375 ...(options?.environment ? { environment: options.environment } : {}),
376 childProcessSpawner,
377 cwd,
378 ...(resumeSessionId ? { resumeSessionId } : {}),
379 clientInfo: { name: "t3-code", version: "0.0.0" },
380 ...acpNativeLoggers,
381 }).pipe(
382 Effect.provideService(Scope.Scope, sessionScope),
383 Effect.mapError(
384 (cause) =>
385 new ProviderAdapterProcessError({
386 provider: PROVIDER,
387 threadId: input.threadId,
388 detail: cause.message,
389 cause,
390 }),
391 ),
392 );
393 const started = yield* Effect.gen(function* () {
394 yield* Effect.forEach(
395 ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
396 (method) =>
397 acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) =>
398 mapAcpCallbackFailure(
399 Effect.gen(function* () {
400 yield* logNative(input.threadId, method, params);
401 const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
402 const runtimeRequestId = RuntimeRequestId.make(requestId);
403 const resolution = yield* Deferred.make<PendingUserInputResolution>();
404 pendingUserInputs.set(requestId, { resolution });
405 yield* offerRuntimeEvent({
406 type: "user-input.requested",
407 ...(yield* makeEventStamp()),
408 provider: PROVIDER,
409 threadId: input.threadId,
410 turnId: sessions.get(input.threadId)?.activeTurnId,
411 requestId: runtimeRequestId,
412 payload: { questions: extractXAiAskUserQuestions(params) },
413 raw: {
414 source: "acp.grok.extension",
415 method,
416 payload: params,
417 },
418 });
419 const resolved = yield* Deferred.await(resolution);
420 pendingUserInputs.delete(requestId);
421 const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {};
422 yield* offerRuntimeEvent({
423 type: "user-input.resolved",
424 ...(yield* makeEventStamp()),
425 provider: PROVIDER,
426 threadId: input.threadId,
427 turnId: sessions.get(input.threadId)?.activeTurnId,
428 requestId: runtimeRequestId,
429 payload: { answers: resolvedAnswers },
430 raw: {
431 source: "acp.grok.extension",
432 method,
433 payload: params,
434 },
435 });
436 switch (resolved._tag) {
437 case "answered":
438 return makeXAiAskUserQuestionResponse(params, resolved.answers);
439 case "cancelled":
440 return makeXAiAskUserQuestionCancelledResponse();
441 }
442 }),
443 ),
444 ),
445 { discard: true },
446 );
447 yield* acp.handleRequestPermission((params) =>
448 mapAcpCallbackFailure(
449 Effect.gen(function* () {
450 yield* logNative(input.threadId, "session/request_permission", params);
451 if (input.runtimeMode === "full-access") {
452 const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
453 if (autoApprovedOptionId !== undefined) {
454 return {
455 outcome: {
456 outcome: "selected" as const,
457 optionId: autoApprovedOptionId,
458 },
459 };
460 }
461 }
462 const permissionRequest = parsePermissionRequest(params);
463 const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
464 const runtimeRequestId = RuntimeRequestId.make(requestId);
465 const decision = yield* Deferred.make<ProviderApprovalDecision>();
466 pendingApprovals.set(requestId, { decision });
467 yield* offerRuntimeEvent(
468 makeAcpRequestOpenedEvent({
469 stamp: yield* makeEventStamp(),
470 provider: PROVIDER,
471 threadId: input.threadId,
472 turnId: sessions.get(input.threadId)?.activeTurnId,
473 requestId: runtimeRequestId,
474 permissionRequest,
475 detail:
476 permissionRequest.detail ??
477 encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ??
478 "[unserializable params]",
479 args: params,
480 source: "acp.jsonrpc",
481 method: "session/request_permission",
482 rawPayload: params,
483 }),
484 );
485 const resolved = yield* Deferred.await(decision);
486 pendingApprovals.delete(requestId);
487 yield* offerRuntimeEvent(
488 makeAcpRequestResolvedEvent({
489 stamp: yield* makeEventStamp(),
490 provider: PROVIDER,
491 threadId: input.threadId,
492 turnId: sessions.get(input.threadId)?.activeTurnId,
493 requestId: runtimeRequestId,
494 permissionRequest,
495 decision: resolved,
496 }),
497 );
498 const selectedOptionId =
499 resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved);
500 return {
501 outcome: selectedOptionId
502 ? {
503 outcome: "selected" as const,
504 optionId: selectedOptionId,
505 }
506 : ({ outcome: "cancelled" } as const),
507 };
508 }),
509 ),
510 );
511 return yield* acp.start();
512 }).pipe(
513 Effect.mapError((error) =>
514 mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error),
515 ),
516 );
517
518 const requestedStartModelId = grokModelSelection?.model
519 ? resolveGrokAcpBaseModelId(grokModelSelection.model)
520 : undefined;
521 const boundModelId = yield* applyGrokAcpModelSelection({
522 runtime: acp,
523 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
524 requestedModelId: requestedStartModelId,
525 mapError: (cause) =>
526 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
527 });
528
529 const now = yield* nowIso;
530 const session: ProviderSession = {
531 provider: PROVIDER,
532 providerInstanceId: boundInstanceId,
533 status: "ready",
534 runtimeMode: input.runtimeMode,
535 cwd,
536 ...(boundModelId ? { model: resolveGrokAcpBaseModelId(boundModelId) } : {}),
537 threadId: input.threadId,
538 resumeCursor: {
539 schemaVersion: GROK_RESUME_VERSION,
540 sessionId: started.sessionId,
541 },
542 createdAt: now,
543 updatedAt: now,
544 };
545
546 const ctx: GrokSessionContext = {
547 threadId: input.threadId,
548 acpSessionId: started.sessionId,
549 session,
550 scope: sessionScope,
551 acp,
552 notificationFiber: undefined,
553 pendingApprovals,
554 pendingUserInputs,
555 turns: [],
556 lastPlanFingerprint: undefined,
557 activeTurnId: undefined,
558 currentModelId: boundModelId,
559 stopped: false,
560 };
561
562 const nf = yield* Stream.runDrain(
563 Stream.mapEffect(acp.getEvents(), (event) =>
564 Effect.gen(function* () {
565 switch (event._tag) {
566 case "AssistantItemStarted":
567 yield* offerRuntimeEvent(
568 makeAcpAssistantItemEvent({
569 stamp: yield* makeEventStamp(),
570 provider: PROVIDER,
571 threadId: ctx.threadId,
572 turnId: ctx.activeTurnId,
573 itemId: event.itemId,
574 lifecycle: "item.started",
575 }),
576 );
577 return;
578 case "AssistantItemCompleted":
579 yield* offerRuntimeEvent(
580 makeAcpAssistantItemEvent({
581 stamp: yield* makeEventStamp(),
582 provider: PROVIDER,
583 threadId: ctx.threadId,
584 turnId: ctx.activeTurnId,
585 itemId: event.itemId,
586 lifecycle: "item.completed",
587 }),
588 );
589 return;
590 case "PlanUpdated":
591 yield* logNative(ctx.threadId, "session/update", event.rawPayload);
592 yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update");
593 return;
594 case "ToolCallUpdated":
595 yield* logNative(ctx.threadId, "session/update", event.rawPayload);
596 yield* offerRuntimeEvent(
597 makeAcpToolCallEvent({
598 stamp: yield* makeEventStamp(),
599 provider: PROVIDER,
600 threadId: ctx.threadId,
601 turnId: ctx.activeTurnId,
602 toolCall: event.toolCall,
603 rawPayload: event.rawPayload,
604 }),
605 );
606 return;
607 case "ContentDelta":
608 yield* logNative(ctx.threadId, "session/update", event.rawPayload);
609 yield* offerRuntimeEvent(
610 makeAcpContentDeltaEvent({
611 stamp: yield* makeEventStamp(),
612 provider: PROVIDER,
613 threadId: ctx.threadId,
614 turnId: ctx.activeTurnId,
615 ...(event.itemId ? { itemId: event.itemId } : {}),
616 text: event.text,
617 rawPayload: event.rawPayload,
618 }),
619 );
620 return;
621 }
622 }),
623 ),
624 ).pipe(
625 Effect.catch((cause) =>
626 Effect.logError("Failed to process Grok runtime notification.", { cause }),
627 ),
628 Effect.forkChild,
629 );
630
631 ctx.notificationFiber = nf;
632 sessions.set(input.threadId, ctx);
633 sessionScopeTransferred = true;
634
635 yield* offerRuntimeEvent({
636 type: "session.started",
637 ...(yield* makeEventStamp()),
638 provider: PROVIDER,
639 threadId: input.threadId,
640 payload: { resume: started.initializeResult },
641 });
642 yield* offerRuntimeEvent({
643 type: "session.state.changed",
644 ...(yield* makeEventStamp()),
645 provider: PROVIDER,
646 threadId: input.threadId,
647 payload: { state: "ready", reason: "Grok ACP session ready" },
648 });
649 yield* offerRuntimeEvent({
650 type: "thread.started",
651 ...(yield* makeEventStamp()),
652 provider: PROVIDER,
653 threadId: input.threadId,
654 payload: { providerThreadId: started.sessionId },
655 });
656
657 return session;
658 }).pipe(Effect.scoped),
659 );
660
661 const sendTurn: GrokAdapterShape["sendTurn"] = (input) =>
662 Effect.gen(function* () {
663 const prepared = yield* withThreadLock(
664 input.threadId,
665 Effect.gen(function* () {
666 const ctx = yield* requireSession(input.threadId);
667 const turnId = TurnId.make(yield* randomUUIDv4);
668 const turnModelSelection =
669 input.modelSelection?.instanceId === boundInstanceId
670 ? input.modelSelection
671 : undefined;
672 const requestedTurnModelId = turnModelSelection?.model
673 ? resolveGrokAcpBaseModelId(turnModelSelection.model)
674 : undefined;
675 const currentModelId = yield* applyGrokAcpModelSelection({
676 runtime: ctx.acp,
677 currentModelId: ctx.currentModelId,
678 requestedModelId: requestedTurnModelId,
679 mapError: (cause) =>
680 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
681 });
682
683 const text = input.input?.trim();
684 const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) =>
685 Effect.gen(function* () {
686 const attachmentPath = resolveAttachmentPath({
687 attachmentsDir: serverConfig.attachmentsDir,
688 attachment,
689 });
690 if (!attachmentPath) {
691 return yield* new ProviderAdapterRequestError({
692 provider: PROVIDER,
693 method: "session/prompt",
694 detail: `Invalid attachment id '${attachment.id}'.`,
695 });
696 }
697 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
698 Effect.mapError(
699 (cause) =>
700 new ProviderAdapterRequestError({
701 provider: PROVIDER,
702 method: "session/prompt",
703 detail: cause.message,
704 cause,
705 }),
706 ),
707 );
708 return {
709 type: "image",
710 data: Buffer.from(bytes).toString("base64"),
711 mimeType: attachment.mimeType,
712 } satisfies EffectAcpSchema.ContentBlock;
713 }),
714 );
715 const promptParts: Array<EffectAcpSchema.ContentBlock> = [
716 ...(text ? [{ type: "text" as const, text }] : []),
717 ...imagePromptParts,
718 ];
719
720 if (promptParts.length === 0) {
721 return yield* new ProviderAdapterValidationError({
722 provider: PROVIDER,
723 operation: "sendTurn",
724 issue: "Turn requires non-empty text or attachments.",
725 });
726 }
727
728 ctx.currentModelId = currentModelId;
729 const displayModel = currentModelId
730 ? resolveGrokAcpBaseModelId(currentModelId)
731 : undefined;
732 ctx.activeTurnId = turnId;
733 ctx.lastPlanFingerprint = undefined;
734 ctx.session = {
735 ...ctx.session,
736 activeTurnId: turnId,
737 updatedAt: yield* nowIso,
738 ...(displayModel ? { model: displayModel } : {}),
739 };
740
741 yield* offerRuntimeEvent({
742 type: "turn.started",
743 ...(yield* makeEventStamp()),
744 provider: PROVIDER,
745 threadId: input.threadId,
746 turnId,
747 payload: displayModel ? { model: displayModel } : {},
748 });
749
750 return {
751 acp: ctx.acp,
752 acpSessionId: ctx.acpSessionId,
753 displayModel,
754 promptParts,
755 turnId,
756 };
757 }),
758 );
759
760 const result = yield* prepared.acp
761 .prompt({
762 prompt: prepared.promptParts,
763 })
764 .pipe(
765 Effect.mapError((error) =>
766 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
767 ),
768 );
769
770 return yield* withThreadLock(
771 input.threadId,
772 Effect.gen(function* () {
773 const ctx = yield* requireSession(input.threadId);
774 if (ctx.acpSessionId !== prepared.acpSessionId) {
775 return yield* new ProviderAdapterRequestError({
776 provider: PROVIDER,
777 method: "session/prompt",
778 detail: "Grok session changed before the turn completed.",
779 });
780 }
781
782 ctx.turns = [
783 ...ctx.turns,
784 { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] },
785 ];
786 ctx.session = {
787 ...ctx.session,
788 activeTurnId: prepared.turnId,
789 updatedAt: yield* nowIso,
790 ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
791 };
792
793 yield* offerRuntimeEvent({
794 type: "turn.completed",
795 ...(yield* makeEventStamp()),
796 provider: PROVIDER,
797 threadId: input.threadId,
798 turnId: prepared.turnId,
799 payload: {
800 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
801 stopReason: result.stopReason ?? null,
802 },
803 });
804
805 return {
806 threadId: input.threadId,
807 turnId: prepared.turnId,
808 resumeCursor: ctx.session.resumeCursor,
809 };
810 }),
811 );
812 });
813
814 const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId) =>
815 Effect.gen(function* () {
816 const ctx = yield* requireSession(threadId);
817 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
818 yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs);
819 yield* Effect.ignore(
820 ctx.acp.cancel.pipe(
821 Effect.mapError((error) =>
822 mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
823 ),
824 ),
825 );
826 });
827
828 const respondToRequest: GrokAdapterShape["respondToRequest"] = (
829 threadId,
830 requestId,
831 decision,
832 ) =>
833 Effect.gen(function* () {
834 const ctx = yield* requireSession(threadId);
835 const pending = ctx.pendingApprovals.get(requestId);
836 if (!pending) {
837 return yield* new ProviderAdapterRequestError({
838 provider: PROVIDER,
839 method: "session/request_permission",
840 detail: `Unknown pending approval request: ${requestId}`,
841 });
842 }
843 yield* Deferred.succeed(pending.decision, decision);
844 });
845
846 const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (
847 threadId,
848 requestId,
849 answers,
850 ) =>
851 Effect.gen(function* () {
852 const ctx = yield* requireSession(threadId);
853 const pending = ctx.pendingUserInputs.get(requestId);
854 if (!pending) {
855 return yield* new ProviderAdapterRequestError({
856 provider: PROVIDER,
857 method: "_x.ai/ask_user_question",
858 detail: `Unknown pending user-input request: ${requestId}`,
859 });
860 }
861 yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers });
862 });
863
864 const readThread: GrokAdapterShape["readThread"] = (threadId) =>
865 Effect.gen(function* () {
866 const ctx = yield* requireSession(threadId);
867 return { threadId, turns: ctx.turns };
868 });
869
870 const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) =>
871 Effect.gen(function* () {
872 yield* requireSession(threadId);
873 if (!Number.isInteger(numTurns) || numTurns < 1) {
874 return yield* new ProviderAdapterValidationError({
875 provider: PROVIDER,
876 operation: "rollbackThread",
877 issue: "numTurns must be an integer >= 1.",
878 });
879 }
880 return yield* new ProviderAdapterRequestError({
881 provider: PROVIDER,
882 method: "thread/rollback",
883 detail: "Grok ACP sessions do not support provider-side rollback yet.",
884 });
885 });
886
887 const stopSession: GrokAdapterShape["stopSession"] = (threadId) =>
888 withThreadLock(
889 threadId,
890 Effect.gen(function* () {
891 const ctx = yield* requireSession(threadId);
892 yield* stopSessionInternal(ctx);
893 }),
894 );
895
896 const listSessions: GrokAdapterShape["listSessions"] = () =>
897 Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session })));
898
899 const hasSession: GrokAdapterShape["hasSession"] = (threadId) =>
900 Effect.sync(() => {
901 const c = sessions.get(threadId);
902 return c !== undefined && !c.stopped;
903 });
904
905 const stopAll: GrokAdapterShape["stopAll"] = () =>
906 Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true });
907
908 yield* Effect.addFinalizer(() =>
909 Effect.ignore(stopAll()).pipe(
910 Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)),
911 Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
912 ),
913 );
914
915 const streamEvents = Stream.fromPubSub(runtimeEventPubSub);
916
917 return {
918 provider: PROVIDER,
919 capabilities: { sessionModelSwitch: "in-session" },
920 startSession,
921 sendTurn,
922 interruptTurn,
923 readThread,
924 rollbackThread,
925 respondToRequest,
926 respondToUserInput,
927 stopSession,
928 listSessions,
929 hasSession,
930 stopAll,
931 streamEvents,
932 } satisfies GrokAdapterShape;
933 });
934}
apps/server/src/provider/Layers/GrokProvider.test.ts created+115
...@@ -0,0 +1,115 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6import { describe, expect, it } from "vite-plus/test";
7import { ChildProcessSpawner } from "effect/unstable/process";
8import { GrokSettings } from "@t3tools/contracts";
9
10import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts";
11
12const decodeGrokSettings = Schema.decodeSync(GrokSettings);
13
14const runNode = <A, E>(
15 effect: Effect.Effect<
16 A,
17 E,
18 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
19 >,
20): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
21
22describe("buildInitialGrokProviderSnapshot", () => {
23 it("returns a disabled snapshot when settings.enabled is false", async () => {
24 const snapshot = await Effect.runPromise(
25 buildInitialGrokProviderSnapshot(decodeGrokSettings({ enabled: false })),
26 );
27 expect(snapshot.enabled).toBe(false);
28 expect(snapshot.status).toBe("disabled");
29 expect(snapshot.installed).toBe(false);
30 expect(snapshot.message).toContain("disabled");
31 });
32
33 it("returns a pending snapshot by default", async () => {
34 const snapshot = await Effect.runPromise(
35 buildInitialGrokProviderSnapshot(decodeGrokSettings({})),
36 );
37 expect(snapshot.enabled).toBe(true);
38 expect(snapshot.installed).toBe(true);
39 expect(snapshot.status).toBe("warning");
40 expect(snapshot.version).toBeNull();
41 expect(snapshot.message).toContain("Checking Grok");
42 expect(snapshot.requiresNewThreadForModelChange).toBe(true);
43 });
44});
45
46describe("checkGrokProviderStatus", () => {
47 it("reports the binary as missing when the binary path does not resolve", async () => {
48 const snapshot = await runNode(
49 checkGrokProviderStatus(
50 decodeGrokSettings({
51 enabled: true,
52 binaryPath: "/definitely/not/installed/grok-binary",
53 }),
54 ),
55 );
56 expect(snapshot.enabled).toBe(true);
57 expect(snapshot.installed).toBe(false);
58 expect(snapshot.status).toBe("error");
59 expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/);
60 });
61
62 it("reports an installed CLI as unhealthy when --version exits non-zero", async () => {
63 const snapshot = await runNode(
64 Effect.scoped(
65 Effect.gen(function* () {
66 const fs = yield* FileSystem.FileSystem;
67 const path = yield* Path.Path;
68 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" });
69 const grokPath = path.join(dir, "grok");
70 yield* fs.writeFileString(
71 grokPath,
72 ["#!/bin/sh", 'printf "%s\\n" "broken grok install" >&2', "exit 2", ""].join("\n"),
73 );
74 yield* fs.chmod(grokPath, 0o755);
75
76 return yield* checkGrokProviderStatus(
77 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
78 );
79 }),
80 ),
81 );
82
83 expect(snapshot.enabled).toBe(true);
84 expect(snapshot.installed).toBe(true);
85 expect(snapshot.status).toBe("error");
86 expect(snapshot.message).toContain("broken grok install");
87 });
88
89 it("reports an error when ACP model discovery is unavailable", async () => {
90 const snapshot = await runNode(
91 Effect.scoped(
92 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" });
96 const grokPath = path.join(dir, "grok");
97 yield* fs.writeFileString(
98 grokPath,
99 ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"),
100 );
101 yield* fs.chmod(grokPath, 0o755);
102
103 return yield* checkGrokProviderStatus(
104 decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
105 );
106 }),
107 ),
108 );
109
110 expect(snapshot.status).toBe("error");
111 expect(snapshot.installed).toBe(true);
112 expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]);
113 expect(snapshot.message).toContain("ACP startup failed");
114 });
115});
apps/server/src/provider/Layers/GrokProvider.ts created+324
...@@ -0,0 +1,324 @@
1import {
2 type GrokSettings,
3 type ModelCapabilities,
4 ProviderDriverKind,
5 type ServerProvider,
6 type ServerProviderModel,
7} from "@t3tools/contracts";
8import type * as EffectAcpSchema from "effect-acp/schema";
9import * as Cause from "effect/Cause";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Option from "effect/Option";
14import * as Result from "effect/Result";
15import { HttpClient } from "effect/unstable/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17import { createModelCapabilities } from "@t3tools/shared/model";
18
19import {
20 buildServerProvider,
21 detailFromResult,
22 isCommandMissingCause,
23 parseGenericCliVersion,
24 providerModelsFromSettings,
25 spawnAndCollect,
26 type ServerProviderDraft,
27} from "../providerSnapshot.ts";
28import {
29 enrichProviderSnapshotWithVersionAdvisory,
30 type ProviderMaintenanceCapabilities,
31} from "../providerMaintenance.ts";
32import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts";
33
34const GROK_PRESENTATION = {
35 displayName: "Grok",
36 badgeLabel: "Early Access",
37 showInteractionModeToggle: false,
38 requiresNewThreadForModelChange: true,
39} as const;
40const PROVIDER = ProviderDriverKind.make("grok");
41const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
42 optionDescriptors: [],
43});
44
45const VERSION_PROBE_TIMEOUT_MS = 4_000;
46const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
47
48const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
49 {
50 slug: "grok-build",
51 name: "Grok Build",
52 isCustom: false,
53 capabilities: EMPTY_CAPABILITIES,
54 },
55];
56
57export function buildInitialGrokProviderSnapshot(
58 grokSettings: GrokSettings,
59): Effect.Effect<ServerProviderDraft> {
60 return Effect.gen(function* () {
61 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
62 const models = grokModelsFromSettings(grokSettings.customModels);
63
64 if (!grokSettings.enabled) {
65 return buildServerProvider({
66 presentation: GROK_PRESENTATION,
67 enabled: false,
68 checkedAt,
69 models,
70 probe: {
71 installed: false,
72 version: null,
73 status: "warning",
74 auth: { status: "unknown" },
75 message: "Grok is disabled in T3 Code settings.",
76 },
77 });
78 }
79
80 return buildServerProvider({
81 presentation: GROK_PRESENTATION,
82 enabled: true,
83 checkedAt,
84 models,
85 probe: {
86 installed: true,
87 version: null,
88 status: "warning",
89 auth: { status: "unknown" },
90 message: "Checking Grok CLI availability...",
91 },
92 });
93 });
94}
95
96function grokModelsFromSettings(
97 customModels: ReadonlyArray<string> | undefined,
98 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
99): ReadonlyArray<ServerProviderModel> {
100 return providerModelsFromSettings(
101 builtInModels,
102 PROVIDER,
103 customModels ?? [],
104 EMPTY_CAPABILITIES,
105 );
106}
107
108function buildGrokDiscoveredModelsFromSessionModelState(
109 modelState: EffectAcpSchema.SessionModelState | null | undefined,
110): ReadonlyArray<ServerProviderModel> {
111 if (!modelState || modelState.availableModels.length === 0) {
112 return [];
113 }
114 const seen = new Set<string>();
115 return modelState.availableModels
116 .map((model): ServerProviderModel | undefined => {
117 const slug = resolveGrokAcpBaseModelId(model.modelId);
118 if (!slug || seen.has(slug)) {
119 return undefined;
120 }
121 seen.add(slug);
122 return {
123 slug,
124 name: model.name.trim() || slug,
125 isCustom: false,
126 capabilities: EMPTY_CAPABILITIES,
127 };
128 })
129 .filter((model): model is ServerProviderModel => model !== undefined);
130}
131
132const discoverGrokModelsViaAcp = (
133 grokSettings: GrokSettings,
134 environment: NodeJS.ProcessEnv = process.env,
135) =>
136 Effect.gen(function* () {
137 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
138 const acp = yield* makeGrokAcpRuntime({
139 grokSettings,
140 environment,
141 childProcessSpawner,
142 cwd: process.cwd(),
143 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
144 });
145 const started = yield* acp.start();
146 return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models);
147 }).pipe(Effect.scoped);
148
149const runGrokVersionCommand = (
150 grokSettings: GrokSettings,
151 environment: NodeJS.ProcessEnv = process.env,
152) => {
153 const command = grokSettings.binaryPath || "grok";
154 return spawnAndCollect(
155 command,
156 ChildProcess.make(command, ["--version"], {
157 env: environment,
158 shell: process.platform === "win32",
159 }),
160 );
161};
162
163export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
164 grokSettings: GrokSettings,
165 environment: NodeJS.ProcessEnv = process.env,
166): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> {
167 const checkedAt = DateTime.formatIso(yield* DateTime.now);
168 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
169
170 if (!grokSettings.enabled) {
171 return buildServerProvider({
172 presentation: GROK_PRESENTATION,
173 enabled: false,
174 checkedAt,
175 models: fallbackModels,
176 probe: {
177 installed: false,
178 version: null,
179 status: "warning",
180 auth: { status: "unknown" },
181 message: "Grok is disabled in T3 Code settings.",
182 },
183 });
184 }
185
186 const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe(
187 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
188 Effect.result,
189 );
190
191 if (Result.isFailure(versionResult)) {
192 const error = versionResult.failure;
193 return buildServerProvider({
194 presentation: GROK_PRESENTATION,
195 enabled: grokSettings.enabled,
196 checkedAt,
197 models: fallbackModels,
198 probe: {
199 installed: !isCommandMissingCause(error),
200 version: null,
201 status: "error",
202 auth: { status: "unknown" },
203 message: isCommandMissingCause(error)
204 ? "Grok CLI (`grok`) is not installed or not on PATH."
205 : `Failed to execute Grok CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
206 },
207 });
208 }
209
210 if (Option.isNone(versionResult.success)) {
211 return buildServerProvider({
212 presentation: GROK_PRESENTATION,
213 enabled: grokSettings.enabled,
214 checkedAt,
215 models: fallbackModels,
216 probe: {
217 installed: true,
218 version: null,
219 status: "error",
220 auth: { status: "unknown" },
221 message: "Grok CLI is installed but timed out while running `grok --version`.",
222 },
223 });
224 }
225
226 const versionOutput = versionResult.success.value;
227 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
228 if (versionOutput.code !== 0) {
229 const detail = detailFromResult(versionOutput);
230 return buildServerProvider({
231 presentation: GROK_PRESENTATION,
232 enabled: grokSettings.enabled,
233 checkedAt,
234 models: fallbackModels,
235 probe: {
236 installed: true,
237 version,
238 status: "error",
239 auth: { status: "unknown" },
240 message: detail
241 ? `Grok CLI is installed but failed to run. ${detail}`
242 : "Grok CLI is installed but failed to run.",
243 },
244 });
245 }
246
247 const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
248 Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
249 Effect.exit,
250 );
251 if (Exit.isFailure(discoveryExit)) {
252 const detail = Cause.pretty(discoveryExit.cause);
253 yield* Effect.logWarning("Grok ACP model discovery failed", { cause: detail });
254 return buildServerProvider({
255 presentation: GROK_PRESENTATION,
256 enabled: grokSettings.enabled,
257 checkedAt,
258 models: fallbackModels,
259 probe: {
260 installed: true,
261 version,
262 status: "error",
263 auth: { status: "unknown" },
264 message: `Grok CLI is installed but ACP startup failed. ${detail}`,
265 },
266 });
267 }
268 if (Option.isNone(discoveryExit.value)) {
269 yield* Effect.logWarning(
270 `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,
271 );
272 return buildServerProvider({
273 presentation: GROK_PRESENTATION,
274 enabled: grokSettings.enabled,
275 checkedAt,
276 models: fallbackModels,
277 probe: {
278 installed: true,
279 version,
280 status: "error",
281 auth: { status: "unknown" },
282 message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,
283 },
284 });
285 }
286 const discoveredModels = discoveryExit.value.value;
287 const models =
288 discoveredModels.length > 0
289 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
290 : fallbackModels;
291
292 return buildServerProvider({
293 presentation: GROK_PRESENTATION,
294 enabled: grokSettings.enabled,
295 checkedAt,
296 models,
297 probe: {
298 installed: true,
299 version,
300 status: "ready",
301 auth: { status: "unknown" },
302 },
303 });
304});
305
306export const enrichGrokSnapshot = (input: {
307 readonly snapshot: ServerProvider;
308 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
309 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
310 readonly httpClient: HttpClient.HttpClient;
311}): Effect.Effect<void> => {
312 const { snapshot, publishSnapshot } = input;
313
314 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe(
315 Effect.provideService(HttpClient.HttpClient, input.httpClient),
316 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
317 Effect.catchCause((cause) =>
318 Effect.logWarning("Grok version advisory enrichment failed", {
319 cause: Cause.pretty(cause),
320 }),
321 ),
322 Effect.asVoid,
323 );
324};
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+47-8
...@@ -10,7 +10,7 @@...@@ -10,7 +10,7 @@
10 *10 *
11 * 2. **Many drivers, one registry** — the "all drivers slice" describe11 * 2. **Many drivers, one registry** — the "all drivers slice" describe
12 * block below configures one instance of every shipped driver12 * block below configures one instance of every shipped driver
13 * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single13 * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single
14 * `ProviderInstanceConfigMap` and asserts the registry boots them all14 * `ProviderInstanceConfigMap` and asserts the registry boots them all
15 * without cross-contamination. This proves the driver SPI is uniform15 * without cross-contamination. This proves the driver SPI is uniform
16 * across every provider — any driver plugs into the registry through16 * across every provider — any driver plugs into the registry through
...@@ -18,7 +18,7 @@...@@ -18,7 +18,7 @@
18 *18 *
19 * Every instance in these tests is configured with `enabled: false` so the19 * Every instance in these tests is configured with `enabled: false` so the
20 * provider-status checks short-circuit to pending/disabled snapshots20 * provider-status checks short-circuit to pending/disabled snapshots
21 * without trying to spawn real `codex` / `claude` / `agent` / `opencode`21 * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode`
22 * binaries. That keeps the assertions focused on registry routing22 * binaries. That keeps the assertions focused on registry routing
23 * behaviour rather than the runtime details of each provider.23 * behaviour rather than the runtime details of each provider.
24 */24 */
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 type ClaudeSettings,28 type ClaudeSettings,
29 type CodexSettings,29 type CodexSettings,
30 type CursorSettings,30 type CursorSettings,
31 type GrokSettings,
31 type OpenCodeSettings,32 type OpenCodeSettings,
32 ProviderDriverKind,33 ProviderDriverKind,
33 type ProviderInstanceConfigMap,34 type ProviderInstanceConfigMap,
...@@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts";...@@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts";
41import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";42import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
42import { CodexDriver } from "../Drivers/CodexDriver.ts";43import { CodexDriver } from "../Drivers/CodexDriver.ts";
43import { CursorDriver } from "../Drivers/CursorDriver.ts";44import { CursorDriver } from "../Drivers/CursorDriver.ts";
45import { GrokDriver } from "../Drivers/GrokDriver.ts";
44import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";46import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
45import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";47import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
46import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";48import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
...@@ -79,6 +81,13 @@ const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings =>...@@ -79,6 +81,13 @@ const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings =>
79 ...overrides,81 ...overrides,
80});82});
8183
84const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({
85 enabled: false,
86 binaryPath: "grok",
87 customModels: [],
88 ...overrides,
89});
90
82const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({91const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({
83 enabled: false,92 enabled: false,
84 binaryPath: "opencode",93 binaryPath: "opencode",
...@@ -218,7 +227,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -218,7 +227,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
218});227});
219228
220describe("ProviderInstanceRegistryLive — all drivers slice", () => {229describe("ProviderInstanceRegistryLive — all drivers slice", () => {
221 // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem +230 // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem +
222 // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime`231 // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime`
223 // at construction time, so we wire `OpenCodeRuntimeLive` into the stack.232 // at construction time, so we wire `OpenCodeRuntimeLive` into the stack.
224 // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via233 // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via
...@@ -244,11 +253,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -244,11 +253,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
244 const codexId = ProviderInstanceId.make("codex_default");253 const codexId = ProviderInstanceId.make("codex_default");
245 const claudeId = ProviderInstanceId.make("claude_default");254 const claudeId = ProviderInstanceId.make("claude_default");
246 const cursorId = ProviderInstanceId.make("cursor_default");255 const cursorId = ProviderInstanceId.make("cursor_default");
256 const grokId = ProviderInstanceId.make("grok_default");
247 const openCodeId = ProviderInstanceId.make("opencode_default");257 const openCodeId = ProviderInstanceId.make("opencode_default");
248258
249 const codexDriverKind = ProviderDriverKind.make("codex");259 const codexDriverKind = ProviderDriverKind.make("codex");
250 const claudeDriverKind = ProviderDriverKind.make("claudeAgent");260 const claudeDriverKind = ProviderDriverKind.make("claudeAgent");
251 const cursorDriverKind = ProviderDriverKind.make("cursor");261 const cursorDriverKind = ProviderDriverKind.make("cursor");
262 const grokDriverKind = ProviderDriverKind.make("grok");
252 const openCodeDriverKind = ProviderDriverKind.make("opencode");263 const openCodeDriverKind = ProviderDriverKind.make("opencode");
253264
254 const configMap: ProviderInstanceConfigMap = {265 const configMap: ProviderInstanceConfigMap = {
...@@ -273,6 +284,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -273,6 +284,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
273 enabled: false,284 enabled: false,
274 config: makeCursorConfig({}),285 config: makeCursorConfig({}),
275 },286 },
287 [grokId]: {
288 driver: grokDriverKind,
289 displayName: "Grok",
290 enabled: false,
291 config: makeGrokConfig({}),
292 },
276 [openCodeId]: {293 [openCodeId]: {
277 driver: openCodeDriverKind,294 driver: openCodeDriverKind,
278 displayName: "OpenCode",295 displayName: "OpenCode",
...@@ -282,7 +299,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -282,7 +299,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
282 };299 };
283300
284 const { registry } = yield* makeProviderInstanceRegistry({301 const { registry } = yield* makeProviderInstanceRegistry({
285 drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver],302 drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
286 configMap,303 configMap,
287 });304 });
288305
...@@ -292,9 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -292,9 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
292 expect(unavailable).toEqual([]);309 expect(unavailable).toEqual([]);
293310
294 const instances = yield* registry.listInstances;311 const instances = yield* registry.listInstances;
295 expect(instances).toHaveLength(4);312 expect(instances).toHaveLength(5);
296 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(313 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
297 [codexId, claudeId, cursorId, openCodeId].toSorted(),314 [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(),
298 );315 );
299316
300 // Instance lookup by id resolves each instance to its own bundle —317 // Instance lookup by id resolves each instance to its own bundle —
...@@ -303,14 +320,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -303,14 +320,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
303 const codex = yield* registry.getInstance(codexId);320 const codex = yield* registry.getInstance(codexId);
304 const claude = yield* registry.getInstance(claudeId);321 const claude = yield* registry.getInstance(claudeId);
305 const cursor = yield* registry.getInstance(cursorId);322 const cursor = yield* registry.getInstance(cursorId);
323 const grok = yield* registry.getInstance(grokId);
306 const openCode = yield* registry.getInstance(openCodeId);324 const openCode = yield* registry.getInstance(openCodeId);
307 expect(codex?.driverKind).toBe(codexDriverKind);325 expect(codex?.driverKind).toBe(codexDriverKind);
308 expect(claude?.driverKind).toBe(claudeDriverKind);326 expect(claude?.driverKind).toBe(claudeDriverKind);
309 expect(cursor?.driverKind).toBe(cursorDriverKind);327 expect(cursor?.driverKind).toBe(cursorDriverKind);
328 expect(grok?.driverKind).toBe(grokDriverKind);
310 expect(openCode?.driverKind).toBe(openCodeDriverKind);329 expect(openCode?.driverKind).toBe(openCodeDriverKind);
311 expect(codex?.displayName).toBe("Codex");330 expect(codex?.displayName).toBe("Codex");
312 expect(claude?.displayName).toBe("Claude");331 expect(claude?.displayName).toBe("Claude");
313 expect(cursor?.displayName).toBe("Cursor");332 expect(cursor?.displayName).toBe("Cursor");
333 expect(grok?.displayName).toBe("Grok");
314 expect(openCode?.displayName).toBe("OpenCode");334 expect(openCode?.displayName).toBe("OpenCode");
315335
316 // Every instance owns its own set of closures — no sharing across336 // Every instance owns its own set of closures — no sharing across
...@@ -318,16 +338,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -318,16 +338,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
318 // distinct references even when two instances happen to share a338 // distinct references even when two instances happen to share a
319 // trait (e.g. Cursor + others all use a stub-or-real339 // trait (e.g. Cursor + others all use a stub-or-real
320 // `textGeneration`; they must still be different object values).340 // `textGeneration`; they must still be different object values).
321 const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter];341 const adapters = [
342 codex!.adapter,
343 claude!.adapter,
344 cursor!.adapter,
345 grok!.adapter,
346 openCode!.adapter,
347 ];
322 expect(new Set(adapters).size).toBe(adapters.length);348 expect(new Set(adapters).size).toBe(adapters.length);
323 const textGenerations = [349 const textGenerations = [
324 codex!.textGeneration,350 codex!.textGeneration,
325 claude!.textGeneration,351 claude!.textGeneration,
326 cursor!.textGeneration,352 cursor!.textGeneration,
353 grok!.textGeneration,
327 openCode!.textGeneration,354 openCode!.textGeneration,
328 ];355 ];
329 expect(new Set(textGenerations).size).toBe(textGenerations.length);356 expect(new Set(textGenerations).size).toBe(textGenerations.length);
330 const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot];357 const snapshots = [
358 codex!.snapshot,
359 claude!.snapshot,
360 cursor!.snapshot,
361 grok!.snapshot,
362 openCode!.snapshot,
363 ];
331 expect(new Set(snapshots).size).toBe(snapshots.length);364 expect(new Set(snapshots).size).toBe(snapshots.length);
332365
333 // Snapshots identify themselves by `instanceId` + `driver` so366 // Snapshots identify themselves by `instanceId` + `driver` so
...@@ -356,6 +389,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -356,6 +389,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
356 `${cursorDriverKind}:instance:${cursorId}`,389 `${cursorDriverKind}:instance:${cursorId}`,
357 );390 );
358391
392 const grokSnapshot = yield* grok!.snapshot.getSnapshot;
393 expect(grokSnapshot.instanceId).toBe(grokId);
394 expect(grokSnapshot.driver).toBe(grokDriverKind);
395 expect(grokSnapshot.enabled).toBe(false);
396 expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`);
397
359 const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot;398 const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot;
360 expect(openCodeSnapshot.instanceId).toBe(openCodeId);399 expect(openCodeSnapshot.instanceId).toBe(openCodeId);
361 expect(openCodeSnapshot.driver).toBe(openCodeDriverKind);400 expect(openCodeSnapshot.driver).toBe(openCodeDriverKind);
apps/server/src/provider/Layers/ProviderRegistry.test.ts+7
...@@ -992,6 +992,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -992,6 +992,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
992 codex: { enabled: false },992 codex: { enabled: false },
993 claudeAgent: { enabled: false },993 claudeAgent: { enabled: false },
994 cursor: { enabled: false },994 cursor: { enabled: false },
995 grok: { enabled: false },
995 opencode: { enabled: false },996 opencode: { enabled: false },
996 },997 },
997 // `providerInstances` keys are branded `ProviderInstanceId`;998 // `providerInstances` keys are branded `ProviderInstanceId`;
...@@ -1086,6 +1087,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1086,6 +1087,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1086 codex: { enabled: true, binaryPath: firstMissing },1087 codex: { enabled: true, binaryPath: firstMissing },
1087 claudeAgent: { enabled: false },1088 claudeAgent: { enabled: false },
1088 cursor: { enabled: false },1089 cursor: { enabled: false },
1090 grok: { enabled: false },
1089 opencode: { enabled: false },1091 opencode: { enabled: false },
1090 },1092 },
1091 }),1093 }),
...@@ -1186,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1186,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1186 codex: { enabled: false },1188 codex: { enabled: false },
1187 claudeAgent: { enabled: false },1189 claudeAgent: { enabled: false },
1188 cursor: { enabled: false },1190 cursor: { enabled: false },
1191 grok: { enabled: false },
1189 opencode: { enabled: false },1192 opencode: { enabled: false },
1190 },1193 },
1191 providerInstances: {1194 providerInstances: {
...@@ -1245,6 +1248,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1245,6 +1248,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1245 cursor: {1248 cursor: {
1246 enabled: false,1249 enabled: false,
1247 },1250 },
1251 grok: {
1252 enabled: false,
1253 },
1248 },1254 },
1249 }),1255 }),
1250 ),1256 ),
...@@ -1305,6 +1311,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1305,6 +1311,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1305 "claudeAgent",1311 "claudeAgent",
1306 "codex",1312 "codex",
1307 "cursor",1313 "cursor",
1314 "grok",
1308 "opencode",1315 "opencode",
1309 ]);1316 ]);
1310 assert.strictEqual(cursorProvider?.enabled, false);1317 assert.strictEqual(cursorProvider?.enabled, false);
apps/server/src/provider/Services/GrokAdapter.ts created+16
...@@ -0,0 +1,16 @@
1/**
2 * GrokAdapter — shape type for the Grok provider adapter.
3 *
4 * The driver model ({@link ../Drivers/GrokDriver}) bundles one adapter per
5 * instance as a captured closure, so this module only retains the shape
6 * interface as a naming anchor for the driver bundle.
7 *
8 * @module GrokAdapter
9 */
10import type { ProviderAdapterError } from "../Errors.ts";
11import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
12
13/**
14 * GrokAdapterShape — per-instance Grok adapter contract.
15 */
16export interface GrokAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}
apps/server/src/provider/acp/AcpSessionRuntime.ts+17
...@@ -105,6 +105,9 @@ export interface AcpSessionRuntimeShape {...@@ -105,6 +105,9 @@ export interface AcpSessionRuntimeShape {
105 value: string | boolean,105 value: string | boolean,
106 ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;106 ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;
107 readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;107 readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
108 readonly setSessionModel: (
109 modelId: string,
110 ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;
108 readonly request: (111 readonly request: (
109 method: string,112 method: string,
110 payload: unknown,113 payload: unknown,
...@@ -546,6 +549,20 @@ const makeAcpSessionRuntime = (...@@ -546,6 +549,20 @@ const makeAcpSessionRuntime = (
546 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),549 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
547 Effect.asVoid,550 Effect.asVoid,
548 ),551 ),
552 setSessionModel: (modelId) =>
553 getStartedState.pipe(
554 Effect.flatMap((started) => {
555 const requestPayload = {
556 sessionId: started.sessionId,
557 modelId,
558 } satisfies EffectAcpSchema.SetSessionModelRequest;
559 return runLoggedRequest(
560 "session/set_model",
561 requestPayload,
562 acp.agent.setSessionModel(requestPayload),
563 );
564 }),
565 ),
549 request: (method, payload) =>566 request: (method, payload) =>
550 runLoggedRequest(method, payload, acp.raw.request(method, payload)),567 runLoggedRequest(method, payload, acp.raw.request(method, payload)),
551 notify: acp.raw.notify,568 notify: acp.raw.notify,
apps/server/src/provider/acp/GrokAcpCliProbe.test.ts created+69
...@@ -0,0 +1,69 @@
1/**
2 * Optional integration check against a real `grok agent stdio` install.
3 * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe
4 *
5 * The probe assumes either `XAI_API_KEY` is set in the environment or
6 * the user has previously run `grok login`. Without credentials the
7 * agent's `authenticate` request will fail and the test will surface
8 * the error.
9 */
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13import { ChildProcessSpawner } from "effect/unstable/process";
14import { describe, expect } from "vite-plus/test";
15
16import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts";
17
18const makeProbeRuntime = Effect.gen(function* () {
19 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20 return yield* makeGrokAcpRuntime({
21 grokSettings: { binaryPath: "grok" },
22 environment: process.env,
23 childProcessSpawner,
24 cwd: process.cwd(),
25 clientInfo: { name: "t3-grok-probe", version: "0.0.0" },
26 });
27});
28
29describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () => {
30 it.effect("initialize and authenticate against real grok agent stdio", () =>
31 Effect.gen(function* () {
32 const runtime = yield* makeProbeRuntime;
33 const started = yield* runtime.start();
34 expect(started.initializeResult).toBeDefined();
35 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
36 );
37
38 it.effect("session/new advertises typed SessionModelState with at least one model", () =>
39 Effect.gen(function* () {
40 const runtime = yield* makeProbeRuntime;
41 const started = yield* runtime.start();
42 const result = started.sessionSetupResult;
43
44 expect(typeof started.sessionId).toBe("string");
45
46 // Modern grok-shell advertises models through the typed
47 // `SessionModelState` field, not via a `configOptions` entry.
48 // If this assertion fails the upstream surface has regressed.
49 const models = result.models;
50 expect(models).toBeDefined();
51 expect(typeof models?.currentModelId).toBe("string");
52 expect(models?.availableModels.length ?? 0).toBeGreaterThan(0);
53 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
54 );
55
56 it.effect("session/set_model accepts a no-op switch to the current model", () =>
57 Effect.gen(function* () {
58 const runtime = yield* makeProbeRuntime;
59 const started = yield* runtime.start();
60 const currentModelId = started.sessionSetupResult.models?.currentModelId?.trim();
61 expect(currentModelId).toBeDefined();
62 if (!currentModelId) return;
63
64 // No-op switch — selecting the model the session already runs on must
65 // succeed against every Grok build that implements `session/set_model`.
66 yield* runtime.setSessionModel(currentModelId);
67 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
68 );
69});
apps/server/src/provider/acp/GrokAcpSupport.test.ts created+109
...@@ -0,0 +1,109 @@
1import * as Effect from "effect/Effect";
2import * as EffectAcpErrors from "effect-acp/errors";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 applyGrokAcpModelSelection,
7 buildGrokAcpSpawnInput,
8 resolveGrokAcpBaseModelId,
9} from "./GrokAcpSupport.ts";
10
11describe("resolveGrokAcpBaseModelId", () => {
12 it("normalizes empty and custom Grok model ids", () => {
13 expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build");
14 expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-build");
15 expect(resolveGrokAcpBaseModelId(" grok-test-custom-model ")).toBe("grok-test-custom-model");
16 });
17});
18
19describe("buildGrokAcpSpawnInput", () => {
20 it("passes the T3 Code referrer through Grok OAuth env", () => {
21 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
22 XAI_API_KEY: "secret",
23 GROK_OAUTH2_REFERRER: "other-client",
24 });
25
26 expect(spawn).toEqual({
27 command: "/usr/local/bin/grok",
28 args: ["agent", "stdio"],
29 cwd: "/tmp/project",
30 env: {
31 XAI_API_KEY: "secret",
32 GROK_OAUTH2_REFERRER: "t3code",
33 },
34 });
35 });
36});
37
38describe("applyGrokAcpModelSelection", () => {
39 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
40 const modelCalls: Array<string> = [];
41 const runtime = {
42 setSessionModel: (modelId: string) =>
43 Effect.gen(function* () {
44 modelCalls.push(modelId);
45 if (failure) return yield* failure;
46 return {};
47 }),
48 };
49 return { runtime, modelCalls };
50 };
51
52 it("calls session/set_model when the requested model differs from current", async () => {
53 const { runtime, modelCalls } = makeRecordingRuntime();
54 const result = await Effect.runPromise(
55 applyGrokAcpModelSelection({
56 runtime,
57 currentModelId: "grok-build",
58 requestedModelId: "grok-mock-alt",
59 mapError: (cause) => cause.message,
60 }),
61 );
62 expect(modelCalls).toEqual(["grok-mock-alt"]);
63 expect(result).toBe("grok-mock-alt");
64 });
65
66 it("skips set_model when requested matches current", async () => {
67 const { runtime, modelCalls } = makeRecordingRuntime();
68 const result = await Effect.runPromise(
69 applyGrokAcpModelSelection({
70 runtime,
71 currentModelId: "grok-build",
72 requestedModelId: "grok-build",
73 mapError: (cause) => cause.message,
74 }),
75 );
76 expect(modelCalls).toEqual([]);
77 expect(result).toBe("grok-build");
78 });
79
80 it("skips set_model when no model is requested", async () => {
81 const { runtime, modelCalls } = makeRecordingRuntime();
82 const result = await Effect.runPromise(
83 applyGrokAcpModelSelection({
84 runtime,
85 currentModelId: "grok-build",
86 requestedModelId: undefined,
87 mapError: (cause) => cause.message,
88 }),
89 );
90 expect(modelCalls).toEqual([]);
91 expect(result).toBe("grok-build");
92 });
93
94 it("propagates session/set_model failures via mapError", async () => {
95 const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known");
96 const { runtime } = makeRecordingRuntime(failure);
97 const error = await Effect.runPromise(
98 Effect.flip(
99 applyGrokAcpModelSelection({
100 runtime,
101 currentModelId: "grok-build",
102 requestedModelId: "grok-mock-alt",
103 mapError: (cause) => cause.message,
104 }),
105 ),
106 );
107 expect(error).toBe(failure.message);
108 });
109});
apps/server/src/provider/acp/GrokAcpSupport.ts created+104
...@@ -0,0 +1,104 @@
1import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Scope from "effect/Scope";
5import { ChildProcessSpawner } from "effect/unstable/process";
6import * as EffectAcpErrors from "effect-acp/errors";
7import type * as EffectAcpSchema from "effect-acp/schema";
8import { normalizeModelSlug } from "@t3tools/shared/model";
9
10import {
11 AcpSessionRuntime,
12 type AcpSessionRuntimeOptions,
13 type AcpSessionRuntimeShape,
14 type AcpSpawnInput,
15} from "./AcpSessionRuntime.ts";
16
17const GROK_API_KEY_ENV = "XAI_API_KEY";
18const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER";
19const T3_CODE_OAUTH_REFERRER = "t3code";
20const GROK_AUTH_METHOD_API_KEY = "xai.api_key";
21const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token";
22const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
23
24type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;
25
26interface GrokAcpRuntimeInput extends Omit<
27 AcpSessionRuntimeOptions,
28 "authMethodId" | "clientCapabilities" | "spawn"
29> {
30 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
31 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
32 readonly environment?: NodeJS.ProcessEnv;
33}
34
35export function buildGrokAcpSpawnInput(
36 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
37 cwd: string,
38 environment?: NodeJS.ProcessEnv,
39): AcpSpawnInput {
40 return {
41 command: grokSettings?.binaryPath || "grok",
42 args: ["agent", "stdio"],
43 cwd,
44 env: {
45 ...environment,
46 [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER,
47 },
48 };
49}
50
51function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string {
52 return environment?.[GROK_API_KEY_ENV]?.trim()
53 ? GROK_AUTH_METHOD_API_KEY
54 : GROK_AUTH_METHOD_CACHED_TOKEN;
55}
56
57export const makeGrokAcpRuntime = (
58 input: GrokAcpRuntimeInput,
59): Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> =>
60 Effect.gen(function* () {
61 const acpContext = yield* Layer.build(
62 AcpSessionRuntime.layer({
63 ...input,
64 spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment),
65 authMethodId: resolveGrokAuthMethodId(input.environment),
66 }).pipe(
67 Layer.provide(
68 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
69 ),
70 ),
71 );
72 return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext));
73 });
74
75export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
76 const trimmed = model?.trim();
77 const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build";
78 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";
79}
80
81export function currentGrokModelIdFromSessionSetup(
82 sessionSetupResult:
83 | EffectAcpSchema.LoadSessionResponse
84 | EffectAcpSchema.NewSessionResponse
85 | EffectAcpSchema.ResumeSessionResponse,
86): string | undefined {
87 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
88}
89
90export function applyGrokAcpModelSelection<E>(input: {
91 readonly runtime: Pick<AcpSessionRuntimeShape, "setSessionModel">;
92 readonly currentModelId: string | undefined;
93 readonly requestedModelId: string | undefined;
94 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
95}): Effect.Effect<string | undefined, E> {
96 const shouldSwitchModel =
97 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
98 if (!shouldSwitchModel) {
99 return Effect.succeed(input.currentModelId);
100 }
101 return input.runtime
102 .setSessionModel(input.requestedModelId)
103 .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));
104}
apps/server/src/provider/acp/XAiAcpExtension.test.ts created+250
...@@ -0,0 +1,250 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import {
5 extractXAiAskUserQuestions,
6 makeXAiAskUserQuestionCancelledResponse,
7 makeXAiAskUserQuestionResponse,
8 XAiAskUserQuestionRequest,
9} from "./XAiAcpExtension.ts";
10
11const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
12
13describe("XAiAcpExtension", () => {
14 it("extracts questions from the real xAI ask_user_question payload shape", () => {
15 const questions = extractXAiAskUserQuestions({
16 sessionId: "session-1",
17 toolCallId: "tool-call-1",
18 mode: "default",
19 questions: [
20 {
21 id: "scope",
22 question: "Which scope should Grok use?",
23 options: [
24 { label: "Workspace", description: "Use the current workspace" },
25 { label: "Session", description: "Only use this session" },
26 ],
27 },
28 ],
29 });
30
31 expect(questions).toEqual([
32 {
33 id: "scope",
34 header: "Question",
35 question: "Which scope should Grok use?",
36 multiSelect: false,
37 options: [
38 { label: "Workspace", description: "Use the current workspace" },
39 { label: "Session", description: "Only use this session" },
40 ],
41 },
42 ]);
43 });
44
45 it("extracts questions from wrapped _x.ai extension payloads", () => {
46 const payload = {
47 method: "_x.ai/ask_user_question",
48 params: {
49 sessionId: "session-1",
50 toolCallId: "tool-call-1",
51 mode: "plan",
52 questions: [
53 {
54 question: "Which changes should be included?",
55 multiSelect: true,
56 options: [{ label: "Tests" }, { label: "Docs" }],
57 },
58 ],
59 },
60 };
61 const decoded = decodeXAiAskUserQuestionRequest(payload);
62 const questions = extractXAiAskUserQuestions(decoded);
63
64 expect(questions).toEqual([
65 {
66 id: "Which changes should be included?",
67 header: "Question",
68 question: "Which changes should be included?",
69 multiSelect: true,
70 options: [
71 { label: "Tests", description: "Tests" },
72 { label: "Docs", description: "Docs" },
73 ],
74 },
75 ]);
76 });
77
78 it("treats nullable multiSelect from Grok as single-select", () => {
79 const questions = extractXAiAskUserQuestions({
80 sessionId: "session-1",
81 toolCallId: "tool-call-1",
82 mode: "default",
83 questions: [
84 {
85 question: "Which label should Grok use?",
86 multiSelect: null,
87 options: [
88 { label: "Alpha", description: "Use the Alpha label" },
89 { label: "Beta", description: "Use the Beta label" },
90 { label: "Other", description: "Use the Other label" },
91 ],
92 },
93 ],
94 });
95
96 expect(questions).toEqual([
97 {
98 id: "Which label should Grok use?",
99 header: "Question",
100 question: "Which label should Grok use?",
101 multiSelect: false,
102 options: [
103 { label: "Alpha", description: "Use the Alpha label" },
104 { label: "Beta", description: "Use the Beta label" },
105 { label: "Other", description: "Use the Other label" },
106 ],
107 },
108 ]);
109 });
110
111 it("maps UI question ids back to xAI question text in accepted responses", () => {
112 const response = makeXAiAskUserQuestionResponse(
113 {
114 sessionId: "session-1",
115 toolCallId: "tool-call-1",
116 mode: "default",
117 questions: [
118 {
119 id: "scope",
120 question: "Which scope should Grok use?",
121 options: [
122 { label: "workspace", description: "Use the current workspace" },
123 { label: "session", description: "Only use this session" },
124 ],
125 },
126 ],
127 },
128 { scope: "workspace" },
129 );
130
131 expect(response).toEqual({
132 outcome: "accepted",
133 answers: {
134 "Which scope should Grok use?": ["workspace"],
135 },
136 });
137 });
138
139 it("orders accepted answers by the original xAI question order", () => {
140 const response = makeXAiAskUserQuestionResponse(
141 {
142 sessionId: "session-1",
143 toolCallId: "tool-call-1",
144 mode: "default",
145 questions: [
146 {
147 id: "first",
148 question: "First question?",
149 options: [{ label: "A", description: "A" }],
150 },
151 {
152 id: "second",
153 question: "Second question?",
154 options: [{ label: "B", description: "B" }],
155 },
156 ],
157 },
158 {
159 second: "B",
160 first: "A",
161 },
162 );
163
164 expect(Object.keys(response.answers)).toEqual(["First question?", "Second question?"]);
165 expect(response).toMatchObject({
166 outcome: "accepted",
167 answers: {
168 "First question?": ["A"],
169 "Second question?": ["B"],
170 },
171 });
172 });
173
174 it("encodes typed custom answers as xAI Other annotations", () => {
175 const response = makeXAiAskUserQuestionResponse(
176 {
177 method: "x.ai/ask_user_question",
178 params: {
179 sessionId: "session-1",
180 toolCallId: "tool-call-1",
181 mode: "default",
182 questions: [
183 {
184 question: "Which ice cream flavor?",
185 options: [
186 { label: "vanilla", description: "Vanilla flavor" },
187 { label: "chocolate", description: "Chocolate flavor" },
188 ],
189 },
190 ],
191 },
192 },
193 { "Which ice cream flavor?": "pistachio" },
194 );
195
196 expect(response).toEqual({
197 outcome: "accepted",
198 answers: {
199 "Which ice cream flavor?": ["Other"],
200 },
201 annotations: {
202 "Which ice cream flavor?": {
203 notes: "pistachio",
204 },
205 },
206 });
207 });
208
209 it("encodes interrupted dialogs as xAI cancelled responses", () => {
210 expect(makeXAiAskUserQuestionCancelledResponse()).toEqual({
211 outcome: "cancelled",
212 });
213 });
214
215 it("does not echo preview annotations for multi-select answers", () => {
216 const response = makeXAiAskUserQuestionResponse(
217 {
218 sessionId: "session-1",
219 toolCallId: "tool-call-1",
220 mode: "default",
221 questions: [
222 {
223 question: "Which files should Grok touch?",
224 multiSelect: true,
225 options: [
226 {
227 label: "Tests",
228 description: "Update tests",
229 preview: "test preview",
230 },
231 {
232 label: "Docs",
233 description: "Update docs",
234 preview: "docs preview",
235 },
236 ],
237 },
238 ],
239 },
240 { "Which files should Grok touch?": ["Tests", "Docs"] },
241 );
242
243 expect(response).toEqual({
244 outcome: "accepted",
245 answers: {
246 "Which files should Grok touch?": ["Tests", "Docs"],
247 },
248 });
249 });
250});
apps/server/src/provider/acp/XAiAcpExtension.ts created+173
...@@ -0,0 +1,173 @@
1import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4const XAiAskUserQuestionOption = Schema.Struct({
5 label: Schema.String,
6 description: Schema.optional(Schema.String),
7 preview: Schema.optional(Schema.String),
8 id: Schema.optional(Schema.String),
9});
10
11const XAiAskUserQuestion = Schema.Struct({
12 id: Schema.optional(Schema.String),
13 question: Schema.String,
14 options: Schema.Array(XAiAskUserQuestionOption),
15 multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)),
16});
17
18const XAiAskUserQuestionParams = Schema.Struct({
19 sessionId: Schema.String,
20 toolCallId: Schema.String,
21 questions: Schema.Array(XAiAskUserQuestion),
22 mode: Schema.Literals(["default", "plan"]),
23});
24
25const XAiWrappedAskUserQuestionParams = Schema.Struct({
26 method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]),
27 params: XAiAskUserQuestionParams,
28});
29
30export const XAiAskUserQuestionRequest = Schema.Union([
31 XAiAskUserQuestionParams,
32 XAiWrappedAskUserQuestionParams,
33]);
34
35type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type;
36type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type;
37
38function trimmed(value: string | undefined): string | undefined {
39 const text = value?.trim();
40 return text && text.length > 0 ? text : undefined;
41}
42
43function unwrapAskUserQuestionParams(
44 params: XAiAskUserQuestionRequest,
45): XAiAskUserQuestionRequestParams {
46 return "params" in params ? params.params : params;
47}
48
49export function extractXAiAskUserQuestions(
50 params: XAiAskUserQuestionRequest,
51): ReadonlyArray<UserInputQuestion> {
52 return unwrapAskUserQuestionParams(params).questions.map((question) => ({
53 id: question.id ?? question.question,
54 header: "Question",
55 question: question.question,
56 multiSelect: question.multiSelect === true,
57 options:
58 question.options.length > 0
59 ? question.options.map((option) => ({
60 label: option.label,
61 description: option.description ?? option.label,
62 }))
63 : [{ label: "OK", description: "Continue" }],
64 }));
65}
66
67interface XAiAskUserQuestionAnnotation {
68 readonly preview?: string;
69 readonly notes?: string;
70}
71
72interface XAiAskUserQuestionAcceptedResponse {
73 readonly outcome: "accepted";
74 readonly answers: Record<string, ReadonlyArray<string>>;
75 readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>;
76}
77
78interface XAiAskUserQuestionCancelledResponse {
79 readonly outcome: "cancelled";
80}
81
82export type XAiAskUserQuestionResponse =
83 | XAiAskUserQuestionAcceptedResponse
84 | XAiAskUserQuestionCancelledResponse;
85
86interface NormalizedXAiAnswer {
87 readonly questionText: string;
88 readonly selectedLabels: ReadonlyArray<string>;
89 readonly annotation?: XAiAskUserQuestionAnnotation;
90}
91
92function answerValues(answer: unknown): ReadonlyArray<string> {
93 if (Array.isArray(answer)) {
94 return answer.flatMap((entry) => {
95 const text = typeof entry === "string" ? trimmed(entry) : undefined;
96 return text ? [text] : [];
97 });
98 }
99 const text = typeof answer === "string" ? trimmed(answer) : undefined;
100 return text ? [text] : [];
101}
102
103function normalizeAnswerForXAi(
104 question: XAiAskUserQuestionRequestParams["questions"][number],
105 answer: unknown,
106): NormalizedXAiAnswer | undefined {
107 const values = answerValues(answer);
108 if (values.length === 0) {
109 return undefined;
110 }
111
112 const optionByLabel = new Map(question.options.map((option) => [option.label, option]));
113 const resolvedValues = values.map((value) => ({
114 value,
115 option: optionByLabel.get(value),
116 }));
117 const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : []));
118 const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value]));
119 const preview =
120 question.multiSelect === true
121 ? undefined
122 : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value);
123
124 const annotation =
125 preview || notes.length > 0
126 ? {
127 ...(preview ? { preview } : {}),
128 ...(notes.length > 0 ? { notes: notes.join("\n") } : {}),
129 }
130 : undefined;
131
132 return {
133 questionText: question.question,
134 selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"],
135 ...(annotation ? { annotation } : {}),
136 };
137}
138
139function findQuestionAnswer(
140 answers: ProviderUserInputAnswers,
141 question: XAiAskUserQuestionRequestParams["questions"][number],
142): unknown {
143 const key = question.id ?? question.question;
144 return answers[key] ?? answers[question.question];
145}
146
147export function makeXAiAskUserQuestionResponse(
148 params: XAiAskUserQuestionRequest,
149 answers: ProviderUserInputAnswers,
150): XAiAskUserQuestionAcceptedResponse {
151 const questions = unwrapAskUserQuestionParams(params).questions;
152 const normalized = questions.flatMap((question) => {
153 const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question));
154 return entry ? [entry] : [];
155 });
156 const annotations = Object.fromEntries(
157 normalized.flatMap((entry) =>
158 entry.annotation ? [[entry.questionText, entry.annotation] as const] : [],
159 ),
160 );
161
162 return {
163 outcome: "accepted",
164 answers: Object.fromEntries(
165 normalized.map((entry) => [entry.questionText, entry.selectedLabels]),
166 ),
167 ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
168 };
169}
170
171export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse {
172 return { outcome: "cancelled" };
173}
apps/server/src/provider/builtInDrivers.ts+3
...@@ -23,6 +23,7 @@...@@ -23,6 +23,7 @@
23import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";23import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
24import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";24import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
26import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
26import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";27import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
27import type { AnyProviderDriver } from "./ProviderDriver.ts";28import type { AnyProviderDriver } from "./ProviderDriver.ts";
2829
...@@ -35,6 +36,7 @@ export type BuiltInDriversEnv =...@@ -35,6 +36,7 @@ export type BuiltInDriversEnv =
35 | ClaudeDriverEnv36 | ClaudeDriverEnv
36 | CodexDriverEnv37 | CodexDriverEnv
37 | CursorDriverEnv38 | CursorDriverEnv
39 | GrokDriverEnv
38 | OpenCodeDriverEnv;40 | OpenCodeDriverEnv;
3941
40/**42/**
...@@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv...@@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
46 CodexDriver,48 CodexDriver,
47 ClaudeDriver,49 ClaudeDriver,
48 CursorDriver,50 CursorDriver,
51 GrokDriver,
49 OpenCodeDriver,52 OpenCodeDriver,
50];53];
apps/server/src/provider/providerSnapshot.ts+4
...@@ -45,6 +45,7 @@ export interface ServerProviderPresentation {...@@ -45,6 +45,7 @@ export interface ServerProviderPresentation {
45 readonly displayName: string;45 readonly displayName: string;
46 readonly badgeLabel?: string;46 readonly badgeLabel?: string;
47 readonly showInteractionModeToggle?: boolean;47 readonly showInteractionModeToggle?: boolean;
48 readonly requiresNewThreadForModelChange?: boolean;
48}49}
4950
50export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;51export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
...@@ -214,6 +215,9 @@ export function buildServerProvider(input: {...@@ -214,6 +215,9 @@ export function buildServerProvider(input: {
214 ...(typeof input.presentation.showInteractionModeToggle === "boolean"215 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
215 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }216 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
216 : {}),217 : {}),
218 ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
219 ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
220 : {}),
217 enabled: input.enabled,221 enabled: input.enabled,
218 installed: input.probe.installed,222 installed: input.probe.installed,
219 version: input.probe.version,223 version: input.probe.version,
apps/server/src/provider/testUtils/providerRegistryMock.ts created+21
...@@ -0,0 +1,21 @@
1import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
2import type { ServerProvider } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
7
8export const makeProviderRegistryMock = (
9 providers: ReadonlyArray<ServerProvider> = [],
10): ProviderRegistryShape => ({
11 getProviders: Effect.succeed(providers),
12 refresh: () => Effect.succeed(providers),
13 refreshInstance: () => Effect.succeed(providers),
14 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
15 Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })),
16 setProviderMaintenanceActionState: () => Effect.succeed(providers),
17 streamChanges: Stream.empty,
18});
19
20export const makeProviderRegistryLayer = (providers: ReadonlyArray<ServerProvider> = []) =>
21 Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers));
apps/server/src/serverSettings.ts+2
...@@ -13,6 +13,7 @@...@@ -13,6 +13,7 @@
13import {13import {
14 DEFAULT_GIT_TEXT_GENERATION_MODEL,14 DEFAULT_GIT_TEXT_GENERATION_MODEL,
15 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,15 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
16 DEFAULT_MODEL_BY_PROVIDER,
16 DEFAULT_SERVER_SETTINGS,17 DEFAULT_SERVER_SETTINGS,
17 isProviderDriverKind,18 isProviderDriverKind,
18 type ModelSelection,19 type ModelSelection,
...@@ -207,6 +208,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting...@@ -207,6 +208,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
207 instanceId: ProviderInstanceId.make(fallback),208 instanceId: ProviderInstanceId.make(fallback),
208 model:209 model:
209 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??210 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
211 DEFAULT_MODEL_BY_PROVIDER[fallback] ??
210 DEFAULT_GIT_TEXT_GENERATION_MODEL,212 DEFAULT_GIT_TEXT_GENERATION_MODEL,
211 } satisfies ModelSelection,213 } satisfies ModelSelection,
212 };214 };
apps/server/src/textGeneration/GrokTextGeneration.test.ts created+234
...@@ -0,0 +1,234 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as path from "node:path";
3import * as os from "node:os";
4import { fileURLToPath } from "node:url";
5import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import { createModelSelection } from "@t3tools/shared/model";
13import { expect } from "vite-plus/test";
14import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts";
15
16import { ServerConfig } from "../config.ts";
17import { type TextGenerationShape } from "./TextGeneration.ts";
18import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";
19const decodeGrokSettings = Schema.decodeSync(GrokSettings);
20
21const __dirname = path.dirname(fileURLToPath(import.meta.url));
22const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts");
23
24function shellSingleQuote(value: string): string {
25 return `'${value.replaceAll("'", `'"'"'`)}'`;
26}
27
28const GrokTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3code-grok-text-generation-test-",
30}).pipe(Layer.provideMerge(NodeServices.layer));
31
32function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {
33 const binDir = path.join(dir, "bin");
34 const grokPath = path.join(binDir, "grok");
35 mkdirSync(binDir, { recursive: true });
36 writeFileSync(
37 grokPath,
38 [
39 "#!/bin/sh",
40 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
41 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then',
42 ' printf "%s\\n" "unexpected args: $*" >&2',
43 " exit 11",
44 "fi",
45 `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`,
46 "",
47 ].join("\n"),
48 "utf8",
49 );
50 chmodSync(grokPath, 0o755);
51 return grokPath;
52}
53
54function withFakeAcpGrok<A, E, R>(
55 env: Record<string, string>,
56 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
57) {
58 return Effect.gen(function* () {
59 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-"));
60 yield* Effect.addFinalizer(() =>
61 Effect.sync(() => {
62 rmSync(tempDir, { recursive: true, force: true });
63 }),
64 );
65 const binaryPath = makeAcpGrokWrapper(tempDir, env);
66 const config = decodeGrokSettings({ binaryPath });
67 const textGeneration = yield* makeGrokTextGeneration(config);
68 return yield* effectFn(textGeneration);
69 }).pipe(Effect.scoped);
70}
71
72function readJsonRpcRequests(
73 filePath: string,
74): ReadonlyArray<{ readonly method?: string; readonly params?: Record<string, unknown> }> {
75 return readFileSync(filePath, "utf8")
76 .trim()
77 .split("\n")
78 .filter((line) => line.length > 0)
79 .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
80}
81
82it.layer(GrokTextGenerationTestLayer)("GrokTextGeneration", (it) => {
83 it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => {
84 const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-log-"));
85 const requestLogPath = path.join(requestLogDir, "requests.ndjson");
86
87 return withFakeAcpGrok(
88 {
89 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
90 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
91 subject: "Add Grok provider",
92 body: "Wire up the ACP runtime and headless text generation path.",
93 }),
94 },
95 (textGeneration) =>
96 Effect.gen(function* () {
97 const generated = yield* textGeneration.generateCommitMessage({
98 cwd: process.cwd(),
99 branch: "feature/grok",
100 stagedSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts",
101 stagedPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts",
102 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"),
103 });
104
105 expect(generated.subject).toBe("Add Grok provider");
106 expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path.");
107
108 const requests = readJsonRpcRequests(requestLogPath);
109 expect(
110 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
111 ).toMatchObject({
112 fs: { readTextFile: false, writeTextFile: false },
113 terminal: false,
114 });
115 expect(
116 requests.some(
117 (request) =>
118 request.method === "session/set_model" &&
119 request.params?.modelId === "grok-mock-alt",
120 ),
121 ).toBe(true);
122 }),
123 );
124 });
125
126 it.effect("extracts the JSON object when Grok wraps it in conversational text", () =>
127 withFakeAcpGrok(
128 {
129 T3_ACP_PROMPT_RESPONSE_TEXT:
130 "Sure! Here's a thread title:\n\n" +
131 JSON.stringify({ title: "Investigate failing CI" }) +
132 "\n\nLet me know if you need anything else.",
133 },
134 (textGeneration) =>
135 Effect.gen(function* () {
136 const generated = yield* textGeneration.generateThreadTitle({
137 cwd: process.cwd(),
138 message: "the lint job is red",
139 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"),
140 });
141 expect(generated.title).toBe("Investigate failing CI");
142 }),
143 ),
144 );
145
146 it.effect("surfaces ACP request failures as text generation errors", () =>
147 withFakeAcpGrok(
148 {
149 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }),
150 },
151 (textGeneration) =>
152 Effect.gen(function* () {
153 const error = yield* Effect.flip(
154 textGeneration.generateBranchName({
155 cwd: process.cwd(),
156 message: "wire up grok",
157 modelSelection: createModelSelection(
158 ProviderInstanceId.make("grok"),
159 "missing-grok-model",
160 ),
161 }),
162 );
163 expect(error._tag).toBe("TextGenerationError");
164 expect(error.detail).toContain("Grok ACP base model");
165 }),
166 ),
167 );
168
169 it.effect("fails with TextGenerationError when output is empty", () =>
170 withFakeAcpGrok(
171 {
172 T3_ACP_PROMPT_RESPONSE_TEXT: " \n ",
173 },
174 (textGeneration) =>
175 Effect.gen(function* () {
176 const error = yield* Effect.flip(
177 textGeneration.generateThreadTitle({
178 cwd: process.cwd(),
179 message: "anything",
180 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"),
181 }),
182 );
183 expect(error._tag).toBe("TextGenerationError");
184 expect(error.detail).toMatch(/empty/i);
185 }),
186 ),
187 );
188
189 it.effect("decodes a structured PR title + body", () =>
190 withFakeAcpGrok(
191 {
192 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
193 title: "feat(grok): wire up session/set_model",
194 body: "## Summary\n- Replace `-m` spawn flag with the typed ACP `session/set_model`.\n- Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT` into a validation error.",
195 }),
196 },
197 (textGeneration) =>
198 Effect.gen(function* () {
199 const generated = yield* textGeneration.generatePrContent({
200 cwd: process.cwd(),
201 baseBranch: "main",
202 headBranch: "feat/grok-provider",
203 commitSummary: "feat: add grok provider",
204 diffSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts",
205 diffPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts",
206 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"),
207 });
208
209 expect(generated.title).toBe("feat(grok): wire up session/set_model");
210 expect(generated.body).toContain("Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT`");
211 }),
212 ),
213 );
214
215 it.effect("fails with TextGenerationError when output is unparseable JSON", () =>
216 withFakeAcpGrok(
217 {
218 T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model",
219 },
220 (textGeneration) =>
221 Effect.gen(function* () {
222 const error = yield* Effect.flip(
223 textGeneration.generateThreadTitle({
224 cwd: process.cwd(),
225 message: "anything",
226 modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"),
227 }),
228 );
229 expect(error._tag).toBe("TextGenerationError");
230 expect(error.detail).toMatch(/invalid structured output/i);
231 }),
232 ),
233 );
234});
apps/server/src/textGeneration/GrokTextGeneration.ts created+272
...@@ -0,0 +1,272 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Ref from "effect/Ref";
4import * as Schema from "effect/Schema";
5import { ChildProcessSpawner } from "effect/unstable/process";
6import type * as EffectAcpErrors from "effect-acp/errors";
7
8import { type GrokSettings, type ModelSelection } from "@t3tools/contracts";
9import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10import { extractJsonObject } from "@t3tools/shared/schemaJson";
11
12import { TextGenerationError } from "@t3tools/contracts";
13import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";
14import {
15 buildBranchNamePrompt,
16 buildCommitMessagePrompt,
17 buildPrContentPrompt,
18 buildThreadTitlePrompt,
19} from "./TextGenerationPrompts.ts";
20import {
21 sanitizeCommitSubject,
22 sanitizePrTitle,
23 sanitizeThreadTitle,
24} from "./TextGenerationUtils.ts";
25import {
26 applyGrokAcpModelSelection,
27 currentGrokModelIdFromSessionSetup,
28 makeGrokAcpRuntime,
29 resolveGrokAcpBaseModelId,
30} from "../provider/acp/GrokAcpSupport.ts";
31
32const GROK_TIMEOUT_MS = 180_000;
33
34function mapGrokAcpError(
35 operation:
36 | "generateCommitMessage"
37 | "generatePrContent"
38 | "generateBranchName"
39 | "generateThreadTitle",
40 detail: string,
41 cause: unknown,
42): TextGenerationError {
43 return new TextGenerationError({
44 operation,
45 detail,
46 ...(cause !== undefined ? { cause } : {}),
47 });
48}
49
50function isTextGenerationError(error: unknown): error is TextGenerationError {
51 return (
52 typeof error === "object" &&
53 error !== null &&
54 "_tag" in error &&
55 error._tag === "TextGenerationError"
56 );
57}
58
59export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* (
60 grokSettings: GrokSettings,
61 environment: NodeJS.ProcessEnv = process.env,
62) {
63 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
64
65 const runGrokJson = <S extends Schema.Top>({
66 operation,
67 cwd,
68 prompt,
69 outputSchemaJson,
70 modelSelection,
71 }: {
72 operation:
73 | "generateCommitMessage"
74 | "generatePrContent"
75 | "generateBranchName"
76 | "generateThreadTitle";
77 cwd: string;
78 prompt: string;
79 outputSchemaJson: S;
80 modelSelection: ModelSelection;
81 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
82 Effect.gen(function* () {
83 const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model);
84 const outputRef = yield* Ref.make("");
85 const runtime = yield* makeGrokAcpRuntime({
86 grokSettings,
87 environment,
88 childProcessSpawner: commandSpawner,
89 cwd,
90 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
91 });
92
93 yield* runtime.handleSessionUpdate((notification) => {
94 const update = notification.update;
95 if (update.sessionUpdate !== "agent_message_chunk") {
96 return Effect.void;
97 }
98 const content = update.content;
99 if (content.type !== "text") {
100 return Effect.void;
101 }
102 return Ref.update(outputRef, (current) => current + content.text);
103 });
104
105 const promptResult = yield* Effect.gen(function* () {
106 const started = yield* runtime.start();
107 yield* applyGrokAcpModelSelection({
108 runtime,
109 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
110 requestedModelId: resolvedModel,
111 mapError: (cause) =>
112 mapGrokAcpError(
113 operation,
114 "Failed to set Grok ACP base model for text generation.",
115 cause,
116 ),
117 });
118
119 return yield* runtime.prompt({
120 prompt: [{ type: "text", text: prompt }],
121 });
122 }).pipe(
123 Effect.timeoutOption(GROK_TIMEOUT_MS),
124 Effect.flatMap(
125 Option.match({
126 onNone: () =>
127 Effect.fail(
128 new TextGenerationError({ operation, detail: "Grok ACP request timed out." }),
129 ),
130 onSome: (value) => Effect.succeed(value),
131 }),
132 ),
133 Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) =>
134 isTextGenerationError(cause)
135 ? cause
136 : mapGrokAcpError(operation, "Grok ACP request failed.", cause),
137 ),
138 );
139
140 const trimmed = (yield* Ref.get(outputRef)).trim();
141 if (!trimmed) {
142 return yield* new TextGenerationError({
143 operation,
144 detail:
145 promptResult.stopReason === "cancelled"
146 ? "Grok ACP request was cancelled."
147 : "Grok Agent returned empty output.",
148 });
149 }
150
151 const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
152 return yield* decodeOutput(extractJsonObject(trimmed)).pipe(
153 Effect.catchTag("SchemaError", (cause) =>
154 Effect.fail(
155 new TextGenerationError({
156 operation,
157 detail: "Grok Agent returned invalid structured output.",
158 cause,
159 }),
160 ),
161 ),
162 );
163 }).pipe(
164 Effect.mapError((cause) =>
165 isTextGenerationError(cause)
166 ? cause
167 : mapGrokAcpError(operation, "Grok ACP text generation failed.", cause),
168 ),
169 Effect.scoped,
170 );
171
172 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
173 "GrokTextGeneration.generateCommitMessage",
174 )(function* (input) {
175 const { prompt, outputSchema } = buildCommitMessagePrompt({
176 branch: input.branch,
177 stagedSummary: input.stagedSummary,
178 stagedPatch: input.stagedPatch,
179 includeBranch: input.includeBranch === true,
180 });
181
182 const generated = yield* runGrokJson({
183 operation: "generateCommitMessage",
184 cwd: input.cwd,
185 prompt,
186 outputSchemaJson: outputSchema,
187 modelSelection: input.modelSelection,
188 });
189
190 return {
191 subject: sanitizeCommitSubject(generated.subject),
192 body: generated.body.trim(),
193 ...("branch" in generated && typeof generated.branch === "string"
194 ? { branch: sanitizeFeatureBranchName(generated.branch) }
195 : {}),
196 };
197 });
198
199 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
200 "GrokTextGeneration.generatePrContent",
201 )(function* (input) {
202 const { prompt, outputSchema } = buildPrContentPrompt({
203 baseBranch: input.baseBranch,
204 headBranch: input.headBranch,
205 commitSummary: input.commitSummary,
206 diffSummary: input.diffSummary,
207 diffPatch: input.diffPatch,
208 });
209
210 const generated = yield* runGrokJson({
211 operation: "generatePrContent",
212 cwd: input.cwd,
213 prompt,
214 outputSchemaJson: outputSchema,
215 modelSelection: input.modelSelection,
216 });
217
218 return {
219 title: sanitizePrTitle(generated.title),
220 body: generated.body.trim(),
221 };
222 });
223
224 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
225 "GrokTextGeneration.generateBranchName",
226 )(function* (input) {
227 const { prompt, outputSchema } = buildBranchNamePrompt({
228 message: input.message,
229 attachments: input.attachments,
230 });
231
232 const generated = yield* runGrokJson({
233 operation: "generateBranchName",
234 cwd: input.cwd,
235 prompt,
236 outputSchemaJson: outputSchema,
237 modelSelection: input.modelSelection,
238 });
239
240 return {
241 branch: sanitizeBranchFragment(generated.branch),
242 };
243 });
244
245 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
246 "GrokTextGeneration.generateThreadTitle",
247 )(function* (input) {
248 const { prompt, outputSchema } = buildThreadTitlePrompt({
249 message: input.message,
250 attachments: input.attachments,
251 });
252
253 const generated = yield* runGrokJson({
254 operation: "generateThreadTitle",
255 cwd: input.cwd,
256 prompt,
257 outputSchemaJson: outputSchema,
258 modelSelection: input.modelSelection,
259 });
260
261 return {
262 title: sanitizeThreadTitle(generated.title),
263 } satisfies ThreadTitleGenerationResult;
264 });
265
266 return {
267 generateCommitMessage,
268 generatePrContent,
269 generateBranchName,
270 generateThreadTitle,
271 } satisfies TextGenerationShape;
272});
apps/server/src/textGeneration/TextGeneration.ts+1-1
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10} from "../provider/Services/ProviderInstanceRegistry.ts";10} from "../provider/Services/ProviderInstanceRegistry.ts";
11import type { ProviderInstance } from "../provider/ProviderDriver.ts";11import type { ProviderInstance } from "../provider/ProviderDriver.ts";
1212
13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
1414
15export interface CommitMessageGenerationInput {15export interface CommitMessageGenerationInput {
16 cwd: string;16 cwd: string;
apps/web/src/clientPersistenceStorage.ts+1-1
...@@ -184,7 +184,7 @@ export function writeBrowserSavedEnvironmentSecret(...@@ -184,7 +184,7 @@ export function writeBrowserSavedEnvironmentSecret(
184 return record;184 return record;
185 }185 }
186 found = true;186 found = true;
187 const nextRecord = {187 const nextRecord: BrowserSavedEnvironmentRecord = {
188 environmentId: record.environmentId,188 environmentId: record.environmentId,
189 label: record.label,189 label: record.label,
190 httpBaseUrl: record.httpBaseUrl,190 httpBaseUrl: record.httpBaseUrl,
apps/web/src/components/ChatView.logic.test.ts+68
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 buildExpiredTerminalContextToastCopy,16 buildExpiredTerminalContextToastCopy,
17 createLocalDispatchSnapshot,17 createLocalDispatchSnapshot,
18 deriveComposerSendState,18 deriveComposerSendState,
19 getStartedThreadModelChangeBlockReason,
19 hasServerAcknowledgedLocalDispatch,20 hasServerAcknowledgedLocalDispatch,
20 reconcileMountedTerminalThreadIds,21 reconcileMountedTerminalThreadIds,
21 resolveSendEnvMode,22 resolveSendEnvMode,
...@@ -90,6 +91,73 @@ describe("buildExpiredTerminalContextToastCopy", () => {...@@ -90,6 +91,73 @@ describe("buildExpiredTerminalContextToastCopy", () => {
90 });91 });
91});92});
9293
94describe("getStartedThreadModelChangeBlockReason", () => {
95 const providers = [
96 {
97 instanceId: ProviderInstanceId.make("codex"),
98 },
99 {
100 instanceId: ProviderInstanceId.make("grok"),
101 requiresNewThreadForModelChange: true,
102 },
103 ];
104
105 it("allows model changes before a provider session has started", () => {
106 expect(
107 getStartedThreadModelChangeBlockReason({
108 providers,
109 hasStartedSession: false,
110 currentModelSelection: {
111 instanceId: ProviderInstanceId.make("grok"),
112 model: "grok-build",
113 },
114 nextModelSelection: {
115 instanceId: ProviderInstanceId.make("grok"),
116 model: "grok-other",
117 },
118 }),
119 ).toBeNull();
120 });
121
122 it("allows unchanged model selections for restricted providers", () => {
123 expect(
124 getStartedThreadModelChangeBlockReason({
125 providers,
126 hasStartedSession: true,
127 currentModelSelection: {
128 instanceId: ProviderInstanceId.make("grok"),
129 model: "grok-build",
130 },
131 nextModelSelection: {
132 instanceId: ProviderInstanceId.make("grok"),
133 model: "grok-build",
134 },
135 }),
136 ).toBeNull();
137 });
138
139 it("blocks started-session model changes when either provider requires a new thread", () => {
140 expect(
141 getStartedThreadModelChangeBlockReason({
142 providers,
143 hasStartedSession: true,
144 currentModelSelection: {
145 instanceId: ProviderInstanceId.make("codex"),
146 model: "gpt-5.4",
147 },
148 nextModelSelection: {
149 instanceId: ProviderInstanceId.make("grok"),
150 model: "grok-build",
151 },
152 }),
153 ).toEqual({
154 title: "Start a new chat to change models",
155 description:
156 "This provider does not allow switching models after a conversation has started.",
157 });
158 });
159});
160
93describe("resolveSendEnvMode", () => {161describe("resolveSendEnvMode", () => {
94 it("keeps worktree mode for git repositories", () => {162 it("keeps worktree mode for git repositories", () => {
95 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");163 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
apps/web/src/components/ChatView.logic.ts+39
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 ProjectId,4 ProjectId,
5 type ModelSelection,5 type ModelSelection,
6 type ProviderDriverKind,6 type ProviderDriverKind,
7 type ServerProvider,
7 type ScopedThreadRef,8 type ScopedThreadRef,
8 type ThreadId,9 type ThreadId,
9 type TurnId,10 type TurnId,
...@@ -262,6 +263,44 @@ export function deriveLockedProvider(input: {...@@ -262,6 +263,44 @@ export function deriveLockedProvider(input: {
262 return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;263 return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;
263}264}
264265
266export function getStartedThreadModelChangeBlockReason(input: {
267 providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "requiresNewThreadForModelChange">>;
268 hasStartedSession: boolean;
269 currentModelSelection: ModelSelection;
270 currentProviderInstanceId?: ModelSelection["instanceId"] | null | undefined;
271 nextModelSelection: ModelSelection;
272}): { title: string; description: string } | null {
273 if (!input.hasStartedSession) {
274 return null;
275 }
276 const currentModelSelection = {
277 ...input.currentModelSelection,
278 instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId,
279 };
280 if (
281 currentModelSelection.instanceId === input.nextModelSelection.instanceId &&
282 currentModelSelection.model === input.nextModelSelection.model
283 ) {
284 return null;
285 }
286 const currentProvider = input.providers.find(
287 (snapshot) => snapshot.instanceId === currentModelSelection.instanceId,
288 );
289 const nextProvider = input.providers.find(
290 (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId,
291 );
292 if (
293 currentProvider?.requiresNewThreadForModelChange !== true &&
294 nextProvider?.requiresNewThreadForModelChange !== true
295 ) {
296 return null;
297 }
298 return {
299 title: "Start a new chat to change models",
300 description: "This provider does not allow switching models after a conversation has started.",
301 };
302}
303
265export async function waitForStartedServerThread(304export async function waitForStartedServerThread(
266 threadRef: ScopedThreadRef,305 threadRef: ScopedThreadRef,
267 timeoutMs = 1_000,306 timeoutMs = 1_000,
apps/web/src/components/ChatView.tsx+29-6
...@@ -163,6 +163,7 @@ import {...@@ -163,6 +163,7 @@ import {
163 createLocalDispatchSnapshot,163 createLocalDispatchSnapshot,
164 deriveComposerSendState,164 deriveComposerSendState,
165 hasServerAcknowledgedLocalDispatch,165 hasServerAcknowledgedLocalDispatch,
166 getStartedThreadModelChangeBlockReason,
166 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,167 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
167 LastInvokedScriptByProjectSchema,168 LastInvokedScriptByProjectSchema,
168 type LocalDispatchSnapshot,169 type LocalDispatchSnapshot,
...@@ -1984,15 +1985,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -1984,15 +1985,18 @@ export default function ChatView(props: ChatViewProps) {
19841985
1985 const focusComposer = useCallback(() => {1986 const focusComposer = useCallback(() => {
1986 composerRef.current?.focusAtEnd();1987 composerRef.current?.focusAtEnd();
1987 }, []);1988 }, [composerRef]);
1988 const scheduleComposerFocus = useCallback(() => {1989 const scheduleComposerFocus = useCallback(() => {
1989 window.requestAnimationFrame(() => {1990 window.requestAnimationFrame(() => {
1990 focusComposer();1991 focusComposer();
1991 });1992 });
1992 }, [focusComposer]);1993 }, [focusComposer]);
1993 const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {1994 const addTerminalContextToDraft = useCallback(
1994 composerRef.current?.addTerminalContext(selection);1995 (selection: TerminalContextSelection) => {
1995 }, []);1996 composerRef.current?.addTerminalContext(selection);
1997 },
1998 [composerRef],
1999 );
1996 const setTerminalOpen = useCallback(2000 const setTerminalOpen = useCallback(
1997 (open: boolean) => {2001 (open: boolean) => {
1998 if (!activeThreadRef) return;2002 if (!activeThreadRef) return;
...@@ -2771,6 +2775,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2771,6 +2775,7 @@ export default function ChatView(props: ChatViewProps) {
2771 keybindings,2775 keybindings,
2772 onToggleDiff,2776 onToggleDiff,
2773 toggleTerminalVisibility,2777 toggleTerminalVisibility,
2778 composerRef,
2774 ]);2779 ]);
27752780
2776 const onRevertToTurnCount = useCallback(2781 const onRevertToTurnCount = useCallback(
...@@ -3245,7 +3250,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3245,7 +3250,7 @@ export default function ChatView(props: ChatViewProps) {
3245 promptRef.current = "";3250 promptRef.current = "";
3246 composerRef.current?.resetCursorState({ cursor: 0 });3251 composerRef.current?.resetCursorState({ cursor: 0 });
3247 },3252 },
3248 [activePendingProgress?.activeQuestion, activePendingUserInput],3253 [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef],
3249 );3254 );
32503255
3251 const onChangeActivePendingUserInputCustomAnswer = useCallback(3256 const onChangeActivePendingUserInputCustomAnswer = useCallback(
...@@ -3279,7 +3284,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3279,7 +3284,7 @@ export default function ChatView(props: ChatViewProps) {
3279 composerRef.current?.focusAt(nextCursor);3284 composerRef.current?.focusAt(nextCursor);
3280 }3285 }
3281 },3286 },
3282 [activePendingUserInput],3287 [activePendingUserInput, composerRef],
3283 );3288 );
32843289
3285 const onAdvanceActivePendingUserInput = useCallback(() => {3290 const onAdvanceActivePendingUserInput = useCallback(() => {
...@@ -3451,6 +3456,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3451,6 +3456,7 @@ export default function ChatView(props: ChatViewProps) {
3451 setThreadError,3456 setThreadError,
3452 autoOpenPlanSidebar,3457 autoOpenPlanSidebar,
3453 environmentId,3458 environmentId,
3459 composerRef,
3454 ],3460 ],
3455 );3461 );
34563462
...@@ -3588,6 +3594,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3588,6 +3594,7 @@ export default function ChatView(props: ChatViewProps) {
3588 runtimeMode,3594 runtimeMode,
3589 autoOpenPlanSidebar,3595 autoOpenPlanSidebar,
3590 environmentId,3596 environmentId,
3597 composerRef,
3591 ]);3598 ]);
35923599
3593 const onProviderModelSelect = useCallback(3600 const onProviderModelSelect = useCallback(
...@@ -3633,6 +3640,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -3633,6 +3640,22 @@ export default function ChatView(props: ChatViewProps) {
3633 instanceId,3640 instanceId,
3634 model: resolvedModel,3641 model: resolvedModel,
3635 };3642 };
3643 const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
3644 providers: providerStatuses,
3645 hasStartedSession: activeThread.session !== null,
3646 currentModelSelection: activeThread.modelSelection,
3647 currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null,
3648 nextModelSelection,
3649 });
3650 if (modelChangeBlockReason) {
3651 toastManager.add({
3652 type: "warning",
3653 title: modelChangeBlockReason.title,
3654 description: modelChangeBlockReason.description,
3655 });
3656 scheduleComposerFocus();
3657 return;
3658 }
3636 setComposerDraftModelSelection(3659 setComposerDraftModelSelection(
3637 scopeThreadRef(activeThread.environmentId, activeThread.id),3660 scopeThreadRef(activeThread.environmentId, activeThread.id),
3638 nextModelSelection,3661 nextModelSelection,
apps/web/src/components/Icons.tsx+12
...@@ -199,6 +199,18 @@ export const CursorIcon: Icon = ({ className, ...props }) => (...@@ -199,6 +199,18 @@ export const CursorIcon: Icon = ({ className, ...props }) => (
199 </svg>199 </svg>
200);200);
201201
202export const GrokIcon: Icon = ({ className, ...props }) => (
203 <svg
204 {...props}
205 viewBox="0 0 24 24"
206 fill="none"
207 className={cn("fill-[#0F0F0F] dark:fill-[#F5F5F5]", className)}
208 >
209 <path d="M9.26905 15.284L17.2479 9.36086C17.6391 9.07047 18.1981 9.18374 18.3845 9.63478C19.3655 12.0135 18.9272 14.8721 16.9755 16.8349C15.0238 18.7976 12.3082 19.228 9.8261 18.2477L7.1146 19.5102C11.0037 22.1834 15.7263 21.5223 18.6774 18.5525C21.0182 16.1985 21.7432 12.9897 21.0653 10.0961L21.0714 10.1023C20.0884 5.85143 21.3131 4.15233 23.8218 0.677913C23.8812 0.595532 23.9406 0.513151 24 0.428711L20.6987 3.74866V3.73836L9.267 15.2861" />
210 <path d="M7.62249 16.7237C4.83113 14.0422 5.3124 9.89222 7.69417 7.49905C9.45541 5.72786 12.341 5.00497 14.86 6.06768L17.5653 4.81138C17.0779 4.45714 16.4533 4.07613 15.7365 3.80839C12.4966 2.46764 8.6178 3.13492 5.98413 5.78141C3.45081 8.32904 2.65415 12.2463 4.02219 15.5889C5.04412 18.0871 3.36889 19.8541 1.68137 21.6377C1.08337 22.2699 0.483318 22.9022 0 23.5716L7.62045 16.7257" />
211 </svg>
212);
213
202export const TraeIcon: Icon = (props) => (214export const TraeIcon: Icon = (props) => (
203 <svg {...props} viewBox="0 0 24 24" fill="currentColor">215 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
204 {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}216 {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}
apps/web/src/components/KeybindingsToast.browser.tsx+1
...@@ -158,6 +158,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -158,6 +158,7 @@ function createBaseServerConfig(): ServerConfig {
158 launchArgs: "",158 launchArgs: "",
159 },159 },
160 cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] },160 cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] },
161 grok: { enabled: true, binaryPath: "", customModels: [] },
161 opencode: {162 opencode: {
162 enabled: true,163 enabled: true,
163 binaryPath: "",164 binaryPath: "",
apps/web/src/components/chat/providerIconUtils.ts+2-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ProviderDriverKind } from "@t3tools/contracts";1import { ProviderDriverKind } from "@t3tools/contracts";
2import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";2import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
3import { PROVIDER_OPTIONS } from "../../session-logic";3import { PROVIDER_OPTIONS } from "../../session-logic";
44
5export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {5export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
...@@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>...@@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>
7 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,7 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
8 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,8 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
9 [ProviderDriverKind.make("cursor")]: CursorIcon,9 [ProviderDriverKind.make("cursor")]: CursorIcon,
10 [ProviderDriverKind.make("grok")]: GrokIcon,
10};11};
1112
12function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {13function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
apps/web/src/components/settings/providerDriverMeta.ts+9-1
...@@ -2,11 +2,12 @@ import {...@@ -2,11 +2,12 @@ import {
2 ClaudeSettings,2 ClaudeSettings,
3 CodexSettings,3 CodexSettings,
4 CursorSettings,4 CursorSettings,
5 GrokSettings,
5 OpenCodeSettings,6 OpenCodeSettings,
6 ProviderDriverKind,7 ProviderDriverKind,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import type * as Schema from "effect/Schema";9import type * as Schema from "effect/Schema";
9import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";10import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
1011
11type ProviderSettingsSchema = {12type ProviderSettingsSchema = {
12 readonly fields: Readonly<Record<string, Schema.Top>>;13 readonly fields: Readonly<Record<string, Schema.Top>>;
...@@ -53,6 +54,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =...@@ -53,6 +54,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
53 badgeLabel: "Early Access",54 badgeLabel: "Early Access",
54 settingsSchema: CursorSettings,55 settingsSchema: CursorSettings,
55 },56 },
57 {
58 value: ProviderDriverKind.make("grok"),
59 label: "Grok",
60 icon: GrokIcon,
61 badgeLabel: "Early Access",
62 settingsSchema: GrokSettings,
63 },
56 {64 {
57 value: ProviderDriverKind.make("opencode"),65 value: ProviderDriverKind.make("opencode"),
58 label: "OpenCode",66 label: "OpenCode",
apps/web/src/environments/runtime/catalog.test.ts+5-1
...@@ -15,6 +15,10 @@ import {...@@ -15,6 +15,10 @@ import {
15 writeSavedEnvironmentCredential,15 writeSavedEnvironmentCredential,
16} from "./catalog";16} from "./catalog";
1717
18let resolveRegistryRead: () => void = () => {
19 throw new Error("Registry read resolver was not initialized.");
20};
21
18describe("environment runtime catalog stores", () => {22describe("environment runtime catalog stores", () => {
19 beforeEach(async () => {23 beforeEach(async () => {
20 vi.stubGlobal("window", {24 vi.stubGlobal("window", {
...@@ -138,7 +142,7 @@ describe("environment runtime catalog stores", () => {...@@ -138,7 +142,7 @@ describe("environment runtime catalog stores", () => {
138 });142 });
139143
140 it("does not let stale hydration overwrite records added while hydration is in flight", async () => {144 it("does not let stale hydration overwrite records added while hydration is in flight", async () => {
141 let resolveRegistryRead: () => void = () => {145 resolveRegistryRead = () => {
142 throw new Error("Registry read resolver was not initialized.");146 throw new Error("Registry read resolver was not initialized.");
143 };147 };
144148
apps/web/src/modelSelection.test.ts+21
...@@ -101,6 +101,27 @@ describe("instance-scoped model selection", () => {...@@ -101,6 +101,27 @@ describe("instance-scoped model selection", () => {
101 ).toBe("openai/gpt-5.5");101 ).toBe("openai/gpt-5.5");
102 });102 });
103103
104 it("includes Grok custom models from the selected provider instance", () => {
105 const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })];
106 const settings: UnifiedSettings = {
107 ...settingsWithProviderInstances(),
108 providerInstances: {
109 ...settingsWithProviderInstances().providerInstances,
110 [ProviderInstanceId.make("grok")]: {
111 driver: ProviderDriverKind.make("grok"),
112 config: { customModels: ["grok-test-custom-model"] },
113 },
114 },
115 };
116 const grok = deriveProviderInstanceEntries(providers).find(
117 (entry) => entry.instanceId === "grok",
118 )!;
119
120 expect(getAppModelOptionsForInstance(settings, grok).map((option) => option.slug)).toContain(
121 "grok-test-custom-model",
122 );
123 });
124
104 it("does not inject an unknown selected slug into the stock instance list", () => {125 it("does not inject an unknown selected slug into the stock instance list", () => {
105 const providers = [126 const providers = [
106 provider({127 provider({
apps/web/src/session-logic.ts+6
...@@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{...@@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{
45 available: true,45 available: true,
46 pickerSidebarBadge: "new",46 pickerSidebarBadge: "new",
47 },47 },
48 {
49 value: ProviderDriverKind.make("grok"),
50 label: "Grok",
51 available: true,
52 pickerSidebarBadge: "new",
53 },
48];54];
4955
50export interface WorkLogEntry {56export interface WorkLogEntry {
packages/contracts/src/model.ts+3
...@@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type;...@@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type;
130const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");130const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
131const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");131const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
133const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
133const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");134const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
134135
135export const DEFAULT_MODEL = "gpt-5.4";136export const DEFAULT_MODEL = "gpt-5.4";
...@@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin...@@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
139 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,140 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
140 [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",141 [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",
141 [CURSOR_DRIVER_KIND]: "auto",142 [CURSOR_DRIVER_KIND]: "auto",
143 [GROK_DRIVER_KIND]: "grok-build",
142 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",144 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
143};145};
144146
...@@ -201,5 +203,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>>...@@ -201,5 +203,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>>
201 [CODEX_DRIVER_KIND]: "Codex",203 [CODEX_DRIVER_KIND]: "Codex",
202 [CLAUDE_DRIVER_KIND]: "Claude",204 [CLAUDE_DRIVER_KIND]: "Claude",
203 [CURSOR_DRIVER_KIND]: "Cursor",205 [CURSOR_DRIVER_KIND]: "Cursor",
206 [GROK_DRIVER_KIND]: "Grok",
204 [OPENCODE_DRIVER_KIND]: "OpenCode",207 [OPENCODE_DRIVER_KIND]: "OpenCode",
205};208};
packages/contracts/src/server.ts+1
...@@ -165,6 +165,7 @@ export const ServerProvider = Schema.Struct({...@@ -165,6 +165,7 @@ export const ServerProvider = Schema.Struct({
165 badgeLabel: Schema.optional(TrimmedNonEmptyString),165 badgeLabel: Schema.optional(TrimmedNonEmptyString),
166 continuation: Schema.optional(ServerProviderContinuation),166 continuation: Schema.optional(ServerProviderContinuation),
167 showInteractionModeToggle: Schema.optional(Schema.Boolean),167 showInteractionModeToggle: Schema.optional(Schema.Boolean),
168 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
168 enabled: Schema.Boolean,169 enabled: Schema.Boolean,
169 installed: Schema.Boolean,170 installed: Schema.Boolean,
170 version: Schema.NullOr(TrimmedNonEmptyString),171 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+32
...@@ -281,6 +281,30 @@ export const CursorSettings = makeProviderSettingsSchema(...@@ -281,6 +281,30 @@ export const CursorSettings = makeProviderSettingsSchema(
281);281);
282export type CursorSettings = typeof CursorSettings.Type;282export type CursorSettings = typeof CursorSettings.Type;
283283
284export const GrokSettings = makeProviderSettingsSchema(
285 {
286 enabled: Schema.Boolean.pipe(
287 Schema.withDecodingDefault(Effect.succeed(true)),
288 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
289 ),
290 binaryPath: makeBinaryPathSetting("grok").pipe(
291 Schema.annotateKey({
292 title: "Binary path",
293 description: "Path to the Grok CLI binary.",
294 providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" },
295 }),
296 ),
297 customModels: Schema.Array(Schema.String).pipe(
298 Schema.withDecodingDefault(Effect.succeed([])),
299 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
300 ),
301 },
302 {
303 order: ["binaryPath"],
304 },
305);
306export type GrokSettings = typeof GrokSettings.Type;
307
284export const OpenCodeSettings = makeProviderSettingsSchema(308export const OpenCodeSettings = makeProviderSettingsSchema(
285 {309 {
286 enabled: Schema.Boolean.pipe(310 enabled: Schema.Boolean.pipe(
...@@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({...@@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({
369 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),393 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
370 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),394 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
371 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),395 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
396 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
372 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),397 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
373 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),398 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
374 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values399 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
...@@ -437,6 +462,12 @@ const CursorSettingsPatch = Schema.Struct({...@@ -437,6 +462,12 @@ const CursorSettingsPatch = Schema.Struct({
437 customModels: Schema.optionalKey(Schema.Array(Schema.String)),462 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
438});463});
439464
465const GrokSettingsPatch = Schema.Struct({
466 enabled: Schema.optionalKey(Schema.Boolean),
467 binaryPath: Schema.optionalKey(TrimmedString),
468 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
469});
470
440const OpenCodeSettingsPatch = Schema.Struct({471const OpenCodeSettingsPatch = Schema.Struct({
441 enabled: Schema.optionalKey(Schema.Boolean),472 enabled: Schema.optionalKey(Schema.Boolean),
442 binaryPath: Schema.optionalKey(TrimmedString),473 binaryPath: Schema.optionalKey(TrimmedString),
...@@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({
463 codex: Schema.optionalKey(CodexSettingsPatch),494 codex: Schema.optionalKey(CodexSettingsPatch),
464 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),495 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
465 cursor: Schema.optionalKey(CursorSettingsPatch),496 cursor: Schema.optionalKey(CursorSettingsPatch),
497 grok: Schema.optionalKey(GrokSettingsPatch),
466 opencode: Schema.optionalKey(OpenCodeSettingsPatch),498 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
467 }),499 }),
468 ),500 ),
packages/shared/src/model.test.ts+3
...@@ -94,6 +94,9 @@ describe("resolveModelSlugForProvider", () => {...@@ -94,6 +94,9 @@ describe("resolveModelSlugForProvider", () => {
94 expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe(94 expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe(
95 DEFAULT_MODEL,95 DEFAULT_MODEL,
96 );96 );
97 expect(resolveModelSlugForProvider(ProviderDriverKind.make("grok"), undefined)).toBe(
98 "grok-build",
99 );
97 });100 });
98101
99 it("preserves normalized unknown models", () => {102 it("preserves normalized unknown models", () => {