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
3535import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
3636import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
3737import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
38import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
3839import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
3940import { ServerSettingsService } from "../src/serverSettings.ts";
4041import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
......@@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = (
293294 Layer.provide(AnalyticsService.layerTest),
294295 Layer.provide(providerEventLoggersLayer),
295296 );
297 const providerRegistryLayer = makeProviderRegistryLayer();
296298
297299 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer));
298300 const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive;
......@@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = (
375377 const layer = Layer.empty.pipe(
376378 Layer.provideMerge(runtimeServicesLayer),
377379 Layer.provideMerge(orchestrationReactorLayer),
380 Layer.provideMerge(providerRegistryLayer),
378381 Layer.provide(persistenceLayer),
379382 Layer.provideMerge(RepositoryIdentityResolverLive),
380383 Layer.provideMerge(ServerSettingsService.layerTest()),
apps/server/scripts/acp-mock-agent.ts+83-3
......@@ -18,9 +18,15 @@ const emitInterleavedAssistantToolCalls =
1818 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
1919const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
2020const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
21const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
2122const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2223const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2324const 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};
2430const sessionId = "mock-session-1";
2531
2632let currentModeId = "ask";
......@@ -237,6 +243,21 @@ function modeState(): AcpSchema.SessionModeState {
237243 };
238244}
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
240261const program = Effect.gen(function* () {
241262 const agent = yield* EffectAcpAgent.AcpAgent;
242263
......@@ -257,6 +278,7 @@ const program = Effect.gen(function* () {
257278 Effect.succeed({
258279 sessionId,
259280 modes: modeState(),
281 models: modelState(),
260282 configOptions: configOptions(),
261283 }),
262284 );
......@@ -273,11 +295,28 @@ const program = Effect.gen(function* () {
273295 .pipe(
274296 Effect.as({
275297 modes: modeState(),
298 models: modelState(),
276299 configOptions: configOptions(),
277300 }),
278301 ),
279302 );
280303
304 yield* agent.handleSetSessionModel((request) =>
305 Effect.gen(function* () {
306 if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {
307 return yield* AcpError.AcpRequestError.invalidParams(
308 `Unknown mock model id: ${request.modelId}`,
309 {
310 method: "session/set_model",
311 params: request,
312 },
313 );
314 }
315 currentModelId = request.modelId;
316 return {};
317 }),
318 );
319
281320 yield* agent.handleSetSessionConfigOption((request) =>
282321 Effect.gen(function* () {
283322 if (exitOnSetConfigOption) {
......@@ -419,9 +458,13 @@ const program = Effect.gen(function* () {
419458 ],
420459 },
421460 options: [
422 { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
423 { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
424 { optionId: "reject-once", name: "Reject", kind: "reject_once" },
461 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
462 {
463 optionId: permissionOptionIds.allowAlways,
464 name: "Allow always",
465 kind: "allow_always",
466 },
467 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
425468 ],
426469 });
427470
......@@ -514,6 +557,43 @@ const program = Effect.gen(function* () {
514557 return { stopReason: "end_turn" };
515558 }
516559
560 if (emitXAiAskUserQuestion) {
561 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
562 method: "x.ai/ask_user_question",
563 params: {
564 sessionId: requestedSessionId,
565 toolCallId: "ask-user-question-tool-call-1",
566 questions: [
567 {
568 question: "Which scope should Grok use?",
569 multiSelect: null,
570 options: [
571 { label: "Workspace", description: "Use the current workspace" },
572 { label: "Session", description: "Only use this session" },
573 ],
574 },
575 ],
576 mode: "default",
577 },
578 });
579 if (typeof result !== "object" || result === null || !("outcome" in result)) {
580 throw new Error("Expected _x.ai/ask_user_question response outcome.");
581 }
582 if (result.outcome === "cancelled") {
583 return { stopReason: "end_turn" };
584 }
585 if (
586 result.outcome !== "accepted" ||
587 !("answers" in result) ||
588 typeof result.answers !== "object" ||
589 result.answers === null
590 ) {
591 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
592 }
593
594 return { stopReason: "end_turn" };
595 }
596
517597 yield* agent.client.sessionUpdate({
518598 sessionId: requestedSessionId,
519599 update: {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+76
......@@ -40,6 +40,7 @@ import {
4040 ProviderService,
4141 type ProviderServiceShape,
4242} from "../../provider/Services/ProviderService.ts";
43import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
4344import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
4445import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
4546import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
......@@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => {
142143 readonly baseDir?: string;
143144 readonly threadModelSelection?: ModelSelection;
144145 readonly sessionModelSwitch?: "unsupported" | "in-session";
146 readonly requiresNewThreadForModelChange?: boolean;
145147 }) {
146148 const now = "2026-01-01T00:00:00.000Z";
147149 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
......@@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => {
280282 }),
281283 ),
282284 );
285 const providerSnapshots = [
286 {
287 instanceId: modelSelection.instanceId,
288 ...(input?.requiresNewThreadForModelChange === true
289 ? { requiresNewThreadForModelChange: true }
290 : {}),
291 },
292 ];
283293
284294 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
285295 const service: ProviderServiceShape = {
......@@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => {
335345 Layer.provideMerge(orchestrationLayer),
336346 Layer.provideMerge(projectionSnapshotLayer),
337347 Layer.provideMerge(Layer.succeed(ProviderService, service)),
348 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
338349 Layer.provideMerge(
339350 Layer.mock(GitWorkflowService)({
340351 renameBranch,
......@@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => {
879890 });
880891 });
881892
893 it("rejects changing models after start when the provider requires a new thread", async () => {
894 const harness = await createHarness({ requiresNewThreadForModelChange: true });
895 const now = "2026-01-01T00:00:00.000Z";
896
897 await Effect.runPromise(
898 harness.engine.dispatch({
899 type: "thread.turn.start",
900 commandId: CommandId.make("cmd-turn-start-restricted-1"),
901 threadId: ThreadId.make("thread-1"),
902 message: {
903 messageId: asMessageId("user-message-restricted-1"),
904 role: "user",
905 text: "first",
906 attachments: [],
907 },
908 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
909 runtimeMode: "approval-required",
910 createdAt: now,
911 }),
912 );
913
914 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
915
916 await Effect.runPromise(
917 harness.engine.dispatch({
918 type: "thread.turn.start",
919 commandId: CommandId.make("cmd-turn-start-restricted-2"),
920 threadId: ThreadId.make("thread-1"),
921 message: {
922 messageId: asMessageId("user-message-restricted-2"),
923 role: "user",
924 text: "second",
925 attachments: [],
926 },
927 modelSelection: {
928 instanceId: ProviderInstanceId.make("codex"),
929 model: "gpt-5.1-codex",
930 },
931 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
932 runtimeMode: "approval-required",
933 createdAt: now,
934 }),
935 );
936
937 await waitFor(async () => {
938 const readModel = await harness.readModel();
939 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
940 return (
941 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
942 false
943 );
944 });
945
946 expect(harness.sendTurn).toHaveBeenCalledTimes(1);
947 const readModel = await harness.readModel();
948 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
949 expect(
950 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
951 ).toMatchObject({
952 payload: {
953 detail: expect.stringContaining("cannot switch models after the conversation has started"),
954 },
955 });
956 });
957
882958 it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => {
883959 const harness = await createHarness({
884960 threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+48
......@@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
3131import type { ProviderServiceError } from "../../provider/Errors.ts";
3232import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
3333import { ProviderService } from "../../provider/Services/ProviderService.ts";
34import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts";
3435import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3536import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
3637import {
......@@ -180,6 +181,7 @@ const make = Effect.gen(function* () {
180181 const orchestrationEngine = yield* OrchestrationEngineService;
181182 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
182183 const providerService = yield* ProviderService;
184 const providerRegistry = yield* ProviderRegistry;
183185 const gitWorkflow = yield* GitWorkflowService;
184186 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
185187 const textGeneration = yield* TextGeneration;
......@@ -305,6 +307,38 @@ const make = Effect.gen(function* () {
305307 .pipe(Effect.map(Option.getOrUndefined));
306308 });
307309
310 const rejectStartedThreadModelChangeIfRequired = Effect.fnUntraced(function* (input: {
311 readonly threadId: ThreadId;
312 readonly currentModelSelection: ModelSelection;
313 readonly requestedModelSelection: ModelSelection | undefined;
314 }) {
315 const requestedModelSelection = input.requestedModelSelection;
316 if (
317 requestedModelSelection === undefined ||
318 (input.currentModelSelection.instanceId === requestedModelSelection.instanceId &&
319 input.currentModelSelection.model === requestedModelSelection.model)
320 ) {
321 return;
322 }
323 const providers = yield* providerRegistry.getProviders;
324 const requiresNewThread =
325 providers.find((snapshot) => snapshot.instanceId === input.currentModelSelection.instanceId)
326 ?.requiresNewThreadForModelChange === true ||
327 providers.find((snapshot) => snapshot.instanceId === requestedModelSelection.instanceId)
328 ?.requiresNewThreadForModelChange === true;
329 if (!requiresNewThread) {
330 return;
331 }
332 return yield* new ProviderAdapterRequestError({
333 provider: providerErrorLabelFromInstanceHint({
334 instanceId: String(requestedModelSelection.instanceId),
335 modelSelectionInstanceId: String(input.currentModelSelection.instanceId),
336 }),
337 method: "thread.turn.start",
338 detail: `Thread '${input.threadId}' cannot switch models after the conversation has started. Start a new thread to use '${requestedModelSelection.model}'.`,
339 });
340 });
341
308342 const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (
309343 threadId: ThreadId,
310344 createdAt: string,
......@@ -384,6 +418,20 @@ const make = Effect.gen(function* () {
384418 });
385419 }
386420 const preferredProvider: ProviderDriverKind = desiredDriverKind;
421 if (thread.session !== null) {
422 yield* rejectStartedThreadModelChangeIfRequired({
423 threadId,
424 currentModelSelection:
425 activeSession?.model !== undefined
426 ? {
427 ...thread.modelSelection,
428 instanceId: currentInstanceId,
429 model: activeSession.model,
430 }
431 : thread.modelSelection,
432 requestedModelSelection,
433 });
434 }
387435 if (
388436 thread.session !== null &&
389437 requestedModelSelection !== undefined &&
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 @@
1010 *
1111 * 2. **Many drivers, one registry** — the "all drivers slice" describe
1212 * block below configures one instance of every shipped driver
13 * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single
13 * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single
1414 * `ProviderInstanceConfigMap` and asserts the registry boots them all
1515 * without cross-contamination. This proves the driver SPI is uniform
1616 * across every provider — any driver plugs into the registry through
......@@ -18,7 +18,7 @@
1818 *
1919 * Every instance in these tests is configured with `enabled: false` so the
2020 * 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`
2222 * binaries. That keeps the assertions focused on registry routing
2323 * behaviour rather than the runtime details of each provider.
2424 */
......@@ -28,6 +28,7 @@ import {
2828 type ClaudeSettings,
2929 type CodexSettings,
3030 type CursorSettings,
31 type GrokSettings,
3132 type OpenCodeSettings,
3233 ProviderDriverKind,
3334 type ProviderInstanceConfigMap,
......@@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts";
4142import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
4243import { CodexDriver } from "../Drivers/CodexDriver.ts";
4344import { CursorDriver } from "../Drivers/CursorDriver.ts";
45import { GrokDriver } from "../Drivers/GrokDriver.ts";
4446import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
4547import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
4648import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
......@@ -79,6 +81,13 @@ const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings =>
7981 ...overrides,
8082});
8183
84const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({
85 enabled: false,
86 binaryPath: "grok",
87 customModels: [],
88 ...overrides,
89});
90
8291const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({
8392 enabled: false,
8493 binaryPath: "opencode",
......@@ -218,7 +227,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
218227});
219228
220229describe("ProviderInstanceRegistryLive — all drivers slice", () => {
221 // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem +
230 // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem +
222231 // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime`
223232 // at construction time, so we wire `OpenCodeRuntimeLive` into the stack.
224233 // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via
......@@ -244,11 +253,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
244253 const codexId = ProviderInstanceId.make("codex_default");
245254 const claudeId = ProviderInstanceId.make("claude_default");
246255 const cursorId = ProviderInstanceId.make("cursor_default");
256 const grokId = ProviderInstanceId.make("grok_default");
247257 const openCodeId = ProviderInstanceId.make("opencode_default");
248258
249259 const codexDriverKind = ProviderDriverKind.make("codex");
250260 const claudeDriverKind = ProviderDriverKind.make("claudeAgent");
251261 const cursorDriverKind = ProviderDriverKind.make("cursor");
262 const grokDriverKind = ProviderDriverKind.make("grok");
252263 const openCodeDriverKind = ProviderDriverKind.make("opencode");
253264
254265 const configMap: ProviderInstanceConfigMap = {
......@@ -273,6 +284,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
273284 enabled: false,
274285 config: makeCursorConfig({}),
275286 },
287 [grokId]: {
288 driver: grokDriverKind,
289 displayName: "Grok",
290 enabled: false,
291 config: makeGrokConfig({}),
292 },
276293 [openCodeId]: {
277294 driver: openCodeDriverKind,
278295 displayName: "OpenCode",
......@@ -282,7 +299,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
282299 };
283300
284301 const { registry } = yield* makeProviderInstanceRegistry({
285 drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver],
302 drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
286303 configMap,
287304 });
288305
......@@ -292,9 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
292309 expect(unavailable).toEqual([]);
293310
294311 const instances = yield* registry.listInstances;
295 expect(instances).toHaveLength(4);
312 expect(instances).toHaveLength(5);
296313 expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual(
297 [codexId, claudeId, cursorId, openCodeId].toSorted(),
314 [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(),
298315 );
299316
300317 // Instance lookup by id resolves each instance to its own bundle —
......@@ -303,14 +320,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
303320 const codex = yield* registry.getInstance(codexId);
304321 const claude = yield* registry.getInstance(claudeId);
305322 const cursor = yield* registry.getInstance(cursorId);
323 const grok = yield* registry.getInstance(grokId);
306324 const openCode = yield* registry.getInstance(openCodeId);
307325 expect(codex?.driverKind).toBe(codexDriverKind);
308326 expect(claude?.driverKind).toBe(claudeDriverKind);
309327 expect(cursor?.driverKind).toBe(cursorDriverKind);
328 expect(grok?.driverKind).toBe(grokDriverKind);
310329 expect(openCode?.driverKind).toBe(openCodeDriverKind);
311330 expect(codex?.displayName).toBe("Codex");
312331 expect(claude?.displayName).toBe("Claude");
313332 expect(cursor?.displayName).toBe("Cursor");
333 expect(grok?.displayName).toBe("Grok");
314334 expect(openCode?.displayName).toBe("OpenCode");
315335
316336 // Every instance owns its own set of closures — no sharing across
......@@ -318,16 +338,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
318338 // distinct references even when two instances happen to share a
319339 // trait (e.g. Cursor + others all use a stub-or-real
320340 // `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 ];
322348 expect(new Set(adapters).size).toBe(adapters.length);
323349 const textGenerations = [
324350 codex!.textGeneration,
325351 claude!.textGeneration,
326352 cursor!.textGeneration,
353 grok!.textGeneration,
327354 openCode!.textGeneration,
328355 ];
329356 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 ];
331364 expect(new Set(snapshots).size).toBe(snapshots.length);
332365
333366 // Snapshots identify themselves by `instanceId` + `driver` so
......@@ -356,6 +389,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
356389 `${cursorDriverKind}:instance:${cursorId}`,
357390 );
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
359398 const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot;
360399 expect(openCodeSnapshot.instanceId).toBe(openCodeId);
361400 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
992992 codex: { enabled: false },
993993 claudeAgent: { enabled: false },
994994 cursor: { enabled: false },
995 grok: { enabled: false },
995996 opencode: { enabled: false },
996997 },
997998 // `providerInstances` keys are branded `ProviderInstanceId`;
......@@ -1086,6 +1087,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
10861087 codex: { enabled: true, binaryPath: firstMissing },
10871088 claudeAgent: { enabled: false },
10881089 cursor: { enabled: false },
1090 grok: { enabled: false },
10891091 opencode: { enabled: false },
10901092 },
10911093 }),
......@@ -1186,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11861188 codex: { enabled: false },
11871189 claudeAgent: { enabled: false },
11881190 cursor: { enabled: false },
1191 grok: { enabled: false },
11891192 opencode: { enabled: false },
11901193 },
11911194 providerInstances: {
......@@ -1245,6 +1248,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
12451248 cursor: {
12461249 enabled: false,
12471250 },
1251 grok: {
1252 enabled: false,
1253 },
12481254 },
12491255 }),
12501256 ),
......@@ -1305,6 +1311,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
13051311 "claudeAgent",
13061312 "codex",
13071313 "cursor",
1314 "grok",
13081315 "opencode",
13091316 ]);
13101317 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 {
105105 value: string | boolean,
106106 ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;
107107 readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
108 readonly setSessionModel: (
109 modelId: string,
110 ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;
108111 readonly request: (
109112 method: string,
110113 payload: unknown,
......@@ -546,6 +549,20 @@ const makeAcpSessionRuntime = (
546549 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
547550 Effect.asVoid,
548551 ),
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 ),
549566 request: (method, payload) =>
550567 runLoggedRequest(method, payload, acp.raw.request(method, payload)),
551568 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 @@
2323import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
2424import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
2525import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
26import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
2627import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
2728import type { AnyProviderDriver } from "./ProviderDriver.ts";
2829
......@@ -35,6 +36,7 @@ export type BuiltInDriversEnv =
3536 | ClaudeDriverEnv
3637 | CodexDriverEnv
3738 | CursorDriverEnv
39 | GrokDriverEnv
3840 | OpenCodeDriverEnv;
3941
4042/**
......@@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
4648 CodexDriver,
4749 ClaudeDriver,
4850 CursorDriver,
51 GrokDriver,
4952 OpenCodeDriver,
5053];
apps/server/src/provider/providerSnapshot.ts+4
......@@ -45,6 +45,7 @@ export interface ServerProviderPresentation {
4545 readonly displayName: string;
4646 readonly badgeLabel?: string;
4747 readonly showInteractionModeToggle?: boolean;
48 readonly requiresNewThreadForModelChange?: boolean;
4849}
4950
5051export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
......@@ -214,6 +215,9 @@ export function buildServerProvider(input: {
214215 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
215216 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
216217 : {}),
218 ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
219 ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
220 : {}),
217221 enabled: input.enabled,
218222 installed: input.probe.installed,
219223 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 @@
1313import {
1414 DEFAULT_GIT_TEXT_GENERATION_MODEL,
1515 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
16 DEFAULT_MODEL_BY_PROVIDER,
1617 DEFAULT_SERVER_SETTINGS,
1718 isProviderDriverKind,
1819 type ModelSelection,
......@@ -207,6 +208,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
207208 instanceId: ProviderInstanceId.make(fallback),
208209 model:
209210 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
211 DEFAULT_MODEL_BY_PROVIDER[fallback] ??
210212 DEFAULT_GIT_TEXT_GENERATION_MODEL,
211213 } satisfies ModelSelection,
212214 };
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 {
1010} from "../provider/Services/ProviderInstanceRegistry.ts";
1111import type { ProviderInstance } from "../provider/ProviderDriver.ts";
1212
13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
1414
1515export interface CommitMessageGenerationInput {
1616 cwd: string;
apps/web/src/clientPersistenceStorage.ts+1-1
......@@ -184,7 +184,7 @@ export function writeBrowserSavedEnvironmentSecret(
184184 return record;
185185 }
186186 found = true;
187 const nextRecord = {
187 const nextRecord: BrowserSavedEnvironmentRecord = {
188188 environmentId: record.environmentId,
189189 label: record.label,
190190 httpBaseUrl: record.httpBaseUrl,
apps/web/src/components/ChatView.logic.test.ts+68
......@@ -16,6 +16,7 @@ import {
1616 buildExpiredTerminalContextToastCopy,
1717 createLocalDispatchSnapshot,
1818 deriveComposerSendState,
19 getStartedThreadModelChangeBlockReason,
1920 hasServerAcknowledgedLocalDispatch,
2021 reconcileMountedTerminalThreadIds,
2122 resolveSendEnvMode,
......@@ -90,6 +91,73 @@ describe("buildExpiredTerminalContextToastCopy", () => {
9091 });
9192});
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
93161describe("resolveSendEnvMode", () => {
94162 it("keeps worktree mode for git repositories", () => {
95163 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
apps/web/src/components/ChatView.logic.ts+39
......@@ -4,6 +4,7 @@ import {
44 ProjectId,
55 type ModelSelection,
66 type ProviderDriverKind,
7 type ServerProvider,
78 type ScopedThreadRef,
89 type ThreadId,
910 type TurnId,
......@@ -262,6 +263,44 @@ export function deriveLockedProvider(input: {
262263 return narrowedThreadProvider ?? narrowedSelectedProvider ?? null;
263264}
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
265304export async function waitForStartedServerThread(
266305 threadRef: ScopedThreadRef,
267306 timeoutMs = 1_000,
apps/web/src/components/ChatView.tsx+29-6
......@@ -163,6 +163,7 @@ import {
163163 createLocalDispatchSnapshot,
164164 deriveComposerSendState,
165165 hasServerAcknowledgedLocalDispatch,
166 getStartedThreadModelChangeBlockReason,
166167 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
167168 LastInvokedScriptByProjectSchema,
168169 type LocalDispatchSnapshot,
......@@ -1984,15 +1985,18 @@ export default function ChatView(props: ChatViewProps) {
19841985
19851986 const focusComposer = useCallback(() => {
19861987 composerRef.current?.focusAtEnd();
1987 }, []);
1988 }, [composerRef]);
19881989 const scheduleComposerFocus = useCallback(() => {
19891990 window.requestAnimationFrame(() => {
19901991 focusComposer();
19911992 });
19921993 }, [focusComposer]);
1993 const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
1994 composerRef.current?.addTerminalContext(selection);
1995 }, []);
1994 const addTerminalContextToDraft = useCallback(
1995 (selection: TerminalContextSelection) => {
1996 composerRef.current?.addTerminalContext(selection);
1997 },
1998 [composerRef],
1999 );
19962000 const setTerminalOpen = useCallback(
19972001 (open: boolean) => {
19982002 if (!activeThreadRef) return;
......@@ -2771,6 +2775,7 @@ export default function ChatView(props: ChatViewProps) {
27712775 keybindings,
27722776 onToggleDiff,
27732777 toggleTerminalVisibility,
2778 composerRef,
27742779 ]);
27752780
27762781 const onRevertToTurnCount = useCallback(
......@@ -3245,7 +3250,7 @@ export default function ChatView(props: ChatViewProps) {
32453250 promptRef.current = "";
32463251 composerRef.current?.resetCursorState({ cursor: 0 });
32473252 },
3248 [activePendingProgress?.activeQuestion, activePendingUserInput],
3253 [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef],
32493254 );
32503255
32513256 const onChangeActivePendingUserInputCustomAnswer = useCallback(
......@@ -3279,7 +3284,7 @@ export default function ChatView(props: ChatViewProps) {
32793284 composerRef.current?.focusAt(nextCursor);
32803285 }
32813286 },
3282 [activePendingUserInput],
3287 [activePendingUserInput, composerRef],
32833288 );
32843289
32853290 const onAdvanceActivePendingUserInput = useCallback(() => {
......@@ -3451,6 +3456,7 @@ export default function ChatView(props: ChatViewProps) {
34513456 setThreadError,
34523457 autoOpenPlanSidebar,
34533458 environmentId,
3459 composerRef,
34543460 ],
34553461 );
34563462
......@@ -3588,6 +3594,7 @@ export default function ChatView(props: ChatViewProps) {
35883594 runtimeMode,
35893595 autoOpenPlanSidebar,
35903596 environmentId,
3597 composerRef,
35913598 ]);
35923599
35933600 const onProviderModelSelect = useCallback(
......@@ -3633,6 +3640,22 @@ export default function ChatView(props: ChatViewProps) {
36333640 instanceId,
36343641 model: resolvedModel,
36353642 };
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 }
36363659 setComposerDraftModelSelection(
36373660 scopeThreadRef(activeThread.environmentId, activeThread.id),
36383661 nextModelSelection,
apps/web/src/components/Icons.tsx+12
......@@ -199,6 +199,18 @@ export const CursorIcon: Icon = ({ className, ...props }) => (
199199 </svg>
200200);
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
202214export const TraeIcon: Icon = (props) => (
203215 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
204216 {/* 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 {
158158 launchArgs: "",
159159 },
160160 cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] },
161 grok: { enabled: true, binaryPath: "", customModels: [] },
161162 opencode: {
162163 enabled: true,
163164 binaryPath: "",
apps/web/src/components/chat/providerIconUtils.ts+2-1
......@@ -1,5 +1,5 @@
11import { ProviderDriverKind } from "@t3tools/contracts";
2import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
2import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
33import { PROVIDER_OPTIONS } from "../../session-logic";
44
55export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
......@@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>
77 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
88 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
99 [ProviderDriverKind.make("cursor")]: CursorIcon,
10 [ProviderDriverKind.make("grok")]: GrokIcon,
1011};
1112
1213function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
apps/web/src/components/settings/providerDriverMeta.ts+9-1
......@@ -2,11 +2,12 @@ import {
22 ClaudeSettings,
33 CodexSettings,
44 CursorSettings,
5 GrokSettings,
56 OpenCodeSettings,
67 ProviderDriverKind,
78} from "@t3tools/contracts";
89import 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
1112type ProviderSettingsSchema = {
1213 readonly fields: Readonly<Record<string, Schema.Top>>;
......@@ -53,6 +54,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
5354 badgeLabel: "Early Access",
5455 settingsSchema: CursorSettings,
5556 },
57 {
58 value: ProviderDriverKind.make("grok"),
59 label: "Grok",
60 icon: GrokIcon,
61 badgeLabel: "Early Access",
62 settingsSchema: GrokSettings,
63 },
5664 {
5765 value: ProviderDriverKind.make("opencode"),
5866 label: "OpenCode",
apps/web/src/environments/runtime/catalog.test.ts+5-1
......@@ -15,6 +15,10 @@ import {
1515 writeSavedEnvironmentCredential,
1616} from "./catalog";
1717
18let resolveRegistryRead: () => void = () => {
19 throw new Error("Registry read resolver was not initialized.");
20};
21
1822describe("environment runtime catalog stores", () => {
1923 beforeEach(async () => {
2024 vi.stubGlobal("window", {
......@@ -138,7 +142,7 @@ describe("environment runtime catalog stores", () => {
138142 });
139143
140144 it("does not let stale hydration overwrite records added while hydration is in flight", async () => {
141 let resolveRegistryRead: () => void = () => {
145 resolveRegistryRead = () => {
142146 throw new Error("Registry read resolver was not initialized.");
143147 };
144148
apps/web/src/modelSelection.test.ts+21
......@@ -101,6 +101,27 @@ describe("instance-scoped model selection", () => {
101101 ).toBe("openai/gpt-5.5");
102102 });
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
104125 it("does not inject an unknown selected slug into the stock instance list", () => {
105126 const providers = [
106127 provider({
apps/web/src/session-logic.ts+6
......@@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{
4545 available: true,
4646 pickerSidebarBadge: "new",
4747 },
48 {
49 value: ProviderDriverKind.make("grok"),
50 label: "Grok",
51 available: true,
52 pickerSidebarBadge: "new",
53 },
4854];
4955
5056export interface WorkLogEntry {
packages/contracts/src/model.ts+3
......@@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type;
130130const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
131131const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
133const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
133134const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
134135
135136export const DEFAULT_MODEL = "gpt-5.4";
......@@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
139140 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
140141 [CLAUDE_DRIVER_KIND]: "claude-sonnet-4-6",
141142 [CURSOR_DRIVER_KIND]: "auto",
143 [GROK_DRIVER_KIND]: "grok-build",
142144 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
143145};
144146
......@@ -201,5 +203,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>>
201203 [CODEX_DRIVER_KIND]: "Codex",
202204 [CLAUDE_DRIVER_KIND]: "Claude",
203205 [CURSOR_DRIVER_KIND]: "Cursor",
206 [GROK_DRIVER_KIND]: "Grok",
204207 [OPENCODE_DRIVER_KIND]: "OpenCode",
205208};
packages/contracts/src/server.ts+1
......@@ -165,6 +165,7 @@ export const ServerProvider = Schema.Struct({
165165 badgeLabel: Schema.optional(TrimmedNonEmptyString),
166166 continuation: Schema.optional(ServerProviderContinuation),
167167 showInteractionModeToggle: Schema.optional(Schema.Boolean),
168 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
168169 enabled: Schema.Boolean,
169170 installed: Schema.Boolean,
170171 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+32
......@@ -281,6 +281,30 @@ export const CursorSettings = makeProviderSettingsSchema(
281281);
282282export 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
284308export const OpenCodeSettings = makeProviderSettingsSchema(
285309 {
286310 enabled: Schema.Boolean.pipe(
......@@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({
369393 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
370394 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
371395 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
396 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
372397 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
373398 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
374399 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
......@@ -437,6 +462,12 @@ const CursorSettingsPatch = Schema.Struct({
437462 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
438463});
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
440471const OpenCodeSettingsPatch = Schema.Struct({
441472 enabled: Schema.optionalKey(Schema.Boolean),
442473 binaryPath: Schema.optionalKey(TrimmedString),
......@@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({
463494 codex: Schema.optionalKey(CodexSettingsPatch),
464495 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
465496 cursor: Schema.optionalKey(CursorSettingsPatch),
497 grok: Schema.optionalKey(GrokSettingsPatch),
466498 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
467499 }),
468500 ),
packages/shared/src/model.test.ts+3
......@@ -94,6 +94,9 @@ describe("resolveModelSlugForProvider", () => {
9494 expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe(
9595 DEFAULT_MODEL,
9696 );
97 expect(resolveModelSlugForProvider(ProviderDriverKind.make("grok"), undefined)).toBe(
98 "grok-build",
99 );
97100 });
98101
99102 it("preserves normalized unknown models", () => {